Compare commits

...
Author SHA1 Message Date
l0ng-ai 37414d4257 test(mcp): expect the platform's line separator and shell on Windows 2026-11-08 22:43:50 +08:00
l0ng-ai 04ab275fe2 fix(mcp): list run configurations without asking and build on Windows
Listing configurations doesn't run anything, so it no longer opens the
confirmation; the run confirmation leaves out an empty working directory.
The Windows build no longer warns about an import only Unix uses.
2026-11-08 22:43:50 +08:00
2bc10df2a9 feat(mcp): add editing, execution confirmations and Git tools
Apply file changes through IDE documents and workspace edits, expose language
references and rename, and ask in the project window before run and build tools.

Co-authored-by: haruka <[email protected]>
Co-authored-by: imacross <[email protected]>
2026-11-08 22:43:50 +08:00
161d9cba41 feat(mcp): add built-in read-only IDE tools
Serve project files, search, symbols and window snapshots through an opt-in
local MCP server, with client configurations and ACP session integration.

Co-authored-by: haruka <[email protected]>
Co-authored-by: imacross <[email protected]>
2026-11-08 22:43:50 +08:00
76 changed files with 11409 additions and 23 deletions

No files matched your search

Generated
+31
View File
@@ -3907,6 +3907,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.15"
@@ -3933,6 +3939,7 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -4112,6 +4119,7 @@ dependencies = [
"encoding_rs",
"ide-core",
"ide-jsonrpc",
"ide-mcp",
"ide-tools",
"ide-vcs",
"ide-vfs",
@@ -4338,6 +4346,7 @@ dependencies = [
"dirs 6.0.0",
"dunce",
"encoding_rs",
"futures",
"getrandom 0.3.4",
"globset",
"ide-agent",
@@ -4354,6 +4363,7 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-lsp",
"ide-mcp",
"ide-notebook",
"ide-plugin",
"ide-remote",
@@ -4372,6 +4382,7 @@ dependencies = [
"ignore",
"parking_lot",
"quick-xml 0.42.0",
"regex-lite",
"regex-syntax",
"reqwest",
"rustix",
@@ -4529,6 +4540,25 @@ dependencies = [
"zip",
]
[[package]]
name = "ide-mcp"
version = "0.1.39"
dependencies = [
"bytes",
"dunce",
"getrandom 0.3.4",
"http-body-util",
"hyper",
"hyper-util",
"percent-encoding",
"serde",
"serde_json",
"tempfile",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "ide-notebook"
version = "0.1.39"
@@ -6400,6 +6430,7 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-lsp",
"ide-mcp",
"ide-notebook",
"ide-plugin",
"ide-profiler",
+1
View File
@@ -42,6 +42,7 @@ ide-fonts = { path = "crates/ide-fonts" }
ide-ssh = { path = "crates/ide-ssh" }
ide-remote = { path = "crates/ide-remote" }
ide-host = { path = "crates/ide-host" }
ide-mcp = { path = "crates/ide-mcp" }
ide-notebook = { path = "crates/ide-notebook" }
ide-extension = { path = "crates/ide-extension" }
ide-plugin = { path = "crates/ide-plugin" }
+2 -3
View File
@@ -9,6 +9,7 @@ license.workspace = true
encoding_rs.workspace = true
ide-vfs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
ide-jsonrpc.workspace = true
ide-vcs.workspace = true
ide-tools = { path = "../ide-tools" }
@@ -21,7 +22,5 @@ tracing.workspace = true
parking_lot.workspace = true
dunce.workspace = true
uuid.workspace = true
which = "8"
[dev-dependencies]
tempfile.workspace = true
which = "8"
+6 -1
View File
@@ -92,6 +92,11 @@ pub(crate) struct Negotiated {
}
impl Negotiated {
pub fn mcp_stdio(&self) -> bool {
// Stdio is the ACP v1 baseline; v2 negotiates each transport explicitly.
self.capabilities.pointer("/mcpCapabilities/stdio").and_then(Value::as_bool).unwrap_or(self.generation == 1)
}
/// Reads an `initialize` response. The shape decides, not `protocolVersion`: some v1
/// agents report version 2 with v1 fields (`agentInfo`, `agentCapabilities`); only a
/// v2 response has `info`.
@@ -120,7 +125,7 @@ impl Negotiated {
"audio": has(prompt, "audio"),
"embeddedContext": has(prompt, "embeddedContext"),
},
"mcpCapabilities": { "http": has(mcp, "http"), "sse": false },
"mcpCapabilities": { "http": has(mcp, "http"), "sse": false, "stdio": has(mcp, "stdio") },
});
if session.is_some() {
let mut sc = json!({ "list": {}, "resume": {}, "close": {} });
+10
View File
@@ -563,3 +563,13 @@ async fn v1_requests_are_sent_in_v2() {
.collect();
assert_eq!(commands, [&json!("28846a4c-c3e8-4a45-a3d0-c19f10d19d1a"), &json!("s1")]);
}
#[test]
fn stdio_mcp_is_v1_baseline_and_negotiated_in_v2() {
assert!(super::Negotiated::from_initialize(&json!({"protocolVersion":1,"agentCapabilities":{}})).mcp_stdio());
assert!(!super::Negotiated::from_initialize(&json!({"info":{},"capabilities":{"session":{}}})).mcp_stdio());
assert!(
super::Negotiated::from_initialize(&json!({"info":{},"capabilities":{"session":{"mcp":{"stdio":{}}}}}))
.mcp_stdio()
);
}
+28 -4
View File
@@ -251,6 +251,7 @@ struct Live {
embedded: bool,
/// `session/load`: the process can be stopped between turns and the session reloaded.
resumable: bool,
mcp_stdio: bool,
}
struct Task {
@@ -696,7 +697,7 @@ impl AgentManager {
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
let reply: Value = live
.peer
.request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": [] }), None)
.request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": mcp_servers_for(live.mcp_stdio, &source.shared.paths.project_root) }), None)
.await
.map_err(|e| IdeError::internal(source.describe(e)))?;
let session = reply
@@ -1449,6 +1450,7 @@ impl Task {
let init = peer.initialize(&client_info()).await.map_err(|e| self.describe(e))?;
let sign_in_message = spec.sign_in_message(&init.auth_methods);
let can_load = init.load_session();
let mcp_stdio = init.mcp_stdio();
let (capabilities, embedded) = capabilities_of(&init);
// Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`).
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
@@ -1457,7 +1459,7 @@ impl Task {
if let (Some(id), true) = (saved, can_load) {
self.replaying.store(true, Ordering::Release);
let loaded: Result<Value, RpcError> =
peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": [] }), None).await;
peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }), None).await;
tokio::time::sleep(REPLAY_GRACE).await;
self.replaying.store(false, Ordering::Release);
match loaded {
@@ -1470,7 +1472,11 @@ impl Task {
Some(s) => s,
None => {
let v: Value = peer
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None)
.request(
"session/new",
json!({ "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }),
None,
)
.await
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
@@ -1561,7 +1567,8 @@ impl Task {
}
self.persist();
self.emit_task();
let live = Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load };
let live =
Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load, mcp_stdio };
*self.live.lock() = Some(live.clone());
Ok(live)
}
@@ -2215,3 +2222,20 @@ mod replay;
mod replay_tests;
#[cfg(test)]
mod tests;
// The desktop advertises its relay only while the built-in server is listening.
static MCP_EXECUTABLE: parking_lot::RwLock<Option<std::path::PathBuf>> = parking_lot::RwLock::new(None);
pub fn set_mcp_executable(executable: Option<std::path::PathBuf>) {
*MCP_EXECUTABLE.write() = executable;
}
fn mcp_servers_for(supported: bool, root: &std::path::Path) -> Vec<Value> {
stdio_mcp_servers(MCP_EXECUTABLE.read().as_deref(), supported, root)
}
fn stdio_mcp_servers(executable: Option<&std::path::Path>, supported: bool, root: &std::path::Path) -> Vec<Value> {
let Some(executable) = executable.filter(|_| supported) else { return Vec::new() };
let command = ide_mcp::stdio_command(executable, root);
vec![json!({ "name": "OpenIDEA", "command": command[0], "args": command[1..], "env": [] })]
}
+65 -4
View File
@@ -98,6 +98,7 @@ pub(super) struct Shim {
struct Session {
id: String,
cwd: PathBuf,
mcp_config: Option<tempfile::TempPath>,
shim: Weak<Shim>,
st: Mutex<State>,
/// Serializes (re)starting the process.
@@ -264,9 +265,24 @@ impl Shim {
Ok(history::chain(&entries, at.as_deref()).into_iter().cloned().collect())
}
fn add_session(self: &Arc<Self>, id: &str, cwd: PathBuf, start: Start, pending: bool) -> Arc<Session> {
fn add_session(
self: &Arc<Self>,
id: &str,
cwd: PathBuf,
start: Start,
pending: bool,
mcp_config: Option<String>,
) -> Arc<Session> {
let mcp_config = mcp_config.and_then(|json| match write_mcp_config(&json) {
Ok(path) => Some(path),
Err(e) => {
tracing::warn!("Claude Code session {id}: cannot write its MCP configuration: {e}");
None
}
});
let session = Arc::new(Session {
id: id.to_owned(),
mcp_config,
shim: Arc::downgrade(self),
st: Mutex::new(State {
proc: None,
@@ -312,6 +328,7 @@ impl Shim {
"loadSession": true,
"promptCapabilities": { "image": true, "embeddedContext": true },
"sessionCapabilities": { "fork": {} },
"mcpCapabilities": { "stdio": true, "http": true, "sse": true },
"_meta": { "openidea": { "rollback": true, "steer": true, "backgroundTasks": true } },
},
"agentInfo": { "name": "claude", "title": "Claude Code", "version": self.version() },
@@ -320,7 +337,13 @@ impl Shim {
}
"session/new" => {
let id = new_uuid(false);
let session = self.add_session(&id, self.cwd_of(&params), Start::New { id: id.clone() }, false);
let session = self.add_session(
&id,
self.cwd_of(&params),
Start::New { id: id.clone() },
false,
mcp_config(&params),
);
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
session.announce_commands();
Ok(json!({ "sessionId": id, "configOptions": session.st.lock().settings.options() }))
@@ -336,7 +359,7 @@ impl Shim {
None => return Err(invalid(format!("no Claude Code session {id} in {}", cwd.display()))),
};
let chain = self.base_chain(&cwd, &start).map_err(internal)?;
let session = self.add_session(id, cwd, start, pending);
let session = self.add_session(id, cwd, start, pending, mcp_config(&params));
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
if method == "session/load" {
session.replay(&chain);
@@ -362,7 +385,7 @@ impl Shim {
(start, st.settings.clone())
};
self.save_pending(&id, Some(&start));
let session = self.add_session(&id, source.cwd.clone(), start, true);
let session = self.add_session(&id, source.cwd.clone(), start, true, mcp_config(&params));
session.st.lock().settings = settings;
let options = session.st.lock().settings.options();
Ok(json!({ "sessionId": id, "configOptions": options }))
@@ -556,6 +579,9 @@ impl Session {
old.end();
}
let mut args = shim.config.args.clone();
if let Some(config) = &self.mcp_config {
args.extend(["--mcp-config".to_owned(), config.to_string_lossy().into_owned()]);
}
args.extend(start.args());
let launch =
Launch { program: shim.config.program.clone(), args, cwd: self.cwd.clone(), env: shim.config.env.clone() };
@@ -1345,3 +1371,38 @@ fn start_from_json(v: &Value) -> Option<Start> {
}
Some(Start::Fork { from: s("fork")?, id: s("id")?, at: s("at") })
}
/// Writes `json` to a new file only this user can read (removed when the path is dropped).
fn write_mcp_config(json: &str) -> std::io::Result<tempfile::TempPath> {
let mut file = tempfile::Builder::new().prefix("openidea-mcp-").suffix(".json").tempfile()?;
std::io::Write::write_all(&mut file, json.as_bytes())?;
Ok(file.into_temp_path())
}
/// ACP `mcpServers` as Claude Code's `--mcp-config` JSON (`None` when there are none).
fn mcp_config(params: &Value) -> Option<String> {
let servers = params.get("mcpServers")?.as_array()?;
let pairs = |v: Option<&Value>| -> serde_json::Map<String, Value> {
v.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|p| Some((p.get("name")?.as_str()?.to_owned(), p.get("value")?.clone())))
.collect()
};
let mut out = serde_json::Map::new();
for server in servers {
let Some(name) = server.get("name").and_then(Value::as_str) else { continue };
let entry = match server.get("type").and_then(Value::as_str) {
Some(kind @ ("http" | "sse")) => {
json!({ "type": kind, "url": server.get("url"), "headers": pairs(server.get("headers")) })
}
_ => json!({
"command": server.get("command"),
"args": server.get("args").cloned().unwrap_or_else(|| json!([])),
"env": pairs(server.get("env")),
}),
};
out.insert(name.to_owned(), entry);
}
(!out.is_empty()).then(|| json!({ "mcpServers": out }).to_string())
}
@@ -1170,3 +1170,29 @@ async fn skipped_questions_have_no_answers() {
let cancelled = ask_questions(&input, &json!({}), |_, _| async { None }).await;
assert_eq!(cancelled, None);
}
#[test]
fn mcp_servers_become_an_mcp_config() {
assert_eq!(mcp_config(&json!({ "mcpServers": [] })), None);
assert_eq!(mcp_config(&json!({})), None);
let config = mcp_config(&json!({ "mcpServers": [
{ "type": "http", "name": "openidea", "url": "http://127.0.0.1:1/mcp",
"headers": [{ "name": "Authorization", "value": "Bearer t" }] },
{ "name": "local", "command": "srv", "args": ["-x"], "env": [{ "name": "K", "value": "V" }] },
] }))
.unwrap();
let v: Value = serde_json::from_str(&config).unwrap();
assert_eq!(
v["mcpServers"]["openidea"],
json!({ "type": "http", "url": "http://127.0.0.1:1/mcp", "headers": { "Authorization": "Bearer t" } })
);
assert_eq!(v["mcpServers"]["local"], json!({ "command": "srv", "args": ["-x"], "env": { "K": "V" } }));
let path = write_mcp_config(&config).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), config);
#[cfg(unix)]
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions()) & 0o077, 0);
let kept = path.to_path_buf();
drop(path);
assert!(!kept.exists());
}
+14 -3
View File
@@ -263,7 +263,7 @@ impl CodexShim {
"agentCapabilities": {
"loadSession": true,
"promptCapabilities": { "image": true, "audio": false, "embeddedContext": true },
"mcpCapabilities": { "http": false, "sse": false },
"mcpCapabilities": { "http": false, "sse": false, "stdio": true },
"sessionCapabilities": { "fork": {}, "close": {} },
"_meta": { "openidea": { "rollback": true, "steer": true } },
},
@@ -317,7 +317,11 @@ impl CodexShim {
async fn new_session(&self, params: &Value) -> Result<Value, ResponseError> {
let models = self.models().await;
let result = self.call("thread/start", json!({ "cwd": params.get("cwd") })).await?;
let mut request = json!({ "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
}
let result = self.call("thread/start", request).await?;
let (id, options) = self.open_thread(&result, &models, None);
if id.is_empty() {
return Err(error(codes::INTERNAL_ERROR, "codex started no thread"));
@@ -329,7 +333,11 @@ impl CodexShim {
async fn load_session(&self, params: &Value) -> Result<Value, ResponseError> {
let id = str_param(params, "sessionId")?;
let models = self.models().await;
let result = self.call("thread/resume", json!({ "threadId": id, "cwd": params.get("cwd") })).await?;
let mut request = json!({ "threadId": id, "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
}
let result = self.call("thread/resume", request).await?;
let (_, options) = self.open_thread(&result, &models, None);
let cwd = self.state.lock().threads.get(id).and_then(|t| t.cwd.clone());
let thread = result.get("thread").unwrap_or(&Value::Null);
@@ -344,6 +352,9 @@ impl CodexShim {
let models = self.models().await;
let settings = self.state.lock().threads.get(source).and_then(|t| t.settings.clone());
let mut request = json!({ "threadId": source, "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
}
if let Some(s) = &settings {
request["model"] = json!(s.model_slug(&models));
request["approvalPolicy"] = json!(s.mode.approval_policy());
@@ -1044,3 +1044,15 @@ fn v_method(line: &str) -> Option<&str> {
let at = line.find("\"method\":\"")? + 10;
line[at..].split('"').next()
}
#[test]
fn session_mcp_servers_become_thread_configuration() {
assert!(mcp_config(&json!({"mcpServers":[]})).is_none());
assert_eq!(
mcp_config(&json!({"mcpServers":[{"name":"OpenIDEA","command":"/app/openidea",
"args":["--mcp-stdio","--project","/project"],"env":[{"name":"K","value":"V"}]}]})),
Some(
json!({"mcp_servers":{"OpenIDEA":{"command":"/app/openidea","args":["--mcp-stdio","--project","/project"],"env":{"K":"V"}}}})
)
);
}
@@ -836,3 +836,31 @@ pub(super) fn tail(text: &str) -> String {
fn str_of(v: &Value, key: &str) -> Option<String> {
v.get(key).and_then(Value::as_str).map(str::to_owned)
}
/// Session-scoped MCP configuration, carried in thread/start, resume and fork.
pub(super) fn mcp_config(params: &Value) -> Option<Value> {
let mut servers = serde_json::Map::new();
for server in params.get("mcpServers")?.as_array()? {
if server.get("type").and_then(Value::as_str).is_some_and(|t| t != "stdio") {
continue;
}
let (Some(name), Some(command)) =
(server.get("name").and_then(Value::as_str), server.get("command").and_then(Value::as_str))
else {
continue;
};
let env: serde_json::Map<String, Value> = server
.get("env")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|v| Some((v.get("name")?.as_str()?.to_owned(), v.get("value")?.clone())))
.collect();
servers.insert(
name.into(),
json!({"command":command,
"args":server.get("args").cloned().unwrap_or_else(||json!([])),"env":env}),
);
}
(!servers.is_empty()).then(|| json!({"mcp_servers":servers}))
}
+14
View File
@@ -2255,3 +2255,17 @@ async fn delete_running_agent(no_load: bool) {
};
assert!(!alive, "the agent (pid {pid}) is still running");
}
#[test]
fn mcp_servers_follow_server_state_and_agent_capabilities() {
let root = std::path::Path::new("/project");
let exe = std::path::Path::new("/app/openidea");
assert!(super::stdio_mcp_servers(None, true, root).is_empty());
assert!(super::stdio_mcp_servers(Some(exe), false, root).is_empty());
let servers = super::stdio_mcp_servers(Some(exe), true, root);
assert_eq!(
servers,
vec![json!({"name":"OpenIDEA","command":"/app/openidea",
"args":["--mcp-stdio","--project","/project"],"env":[]})]
);
}
+14
View File
@@ -10,9 +10,23 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum McpUiRequest {
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum IdeEvent {
McpRequest {
id: u32,
request: McpUiRequest,
},
McpRequestClosed {
id: u32,
},
/// Project window membership or selection changed.
WindowGroupsChanged,
/// A window entered or left full screen, itself or as a member of a full-screen window group.
+11
View File
@@ -61,6 +61,8 @@ pub struct Settings {
pub cmake: CMakeSettings,
/// Tools › Agents › Chat.
pub agents: AgentSettings,
/// Tools › MCP Server (global only).
pub mcp_server: McpServerSettings,
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
@@ -86,6 +88,14 @@ impl Default for AgentSettings {
}
}
/// Tools › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
/// Serve the tools on 127.0.0.1 while the IDE runs.
pub enabled: bool,
}
/// Absent profiles retain automatic discovery for existing projects.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -230,6 +240,7 @@ impl Default for Settings {
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
agents: AgentSettings::default(),
mcp_server: McpServerSettings::default(),
plugins: BTreeMap::new(),
}
}
+3
View File
@@ -12,9 +12,12 @@ path = "src/bin/server.rs"
[dependencies]
encoding_rs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
futures.workspace = true
ide-text.workspace = true
ide-vfs.workspace = true
regex-syntax.workspace = true
regex-lite = "0.1"
ide-workspace.workspace = true
ide-language.workspace = true
blake3.workspace = true
+7
View File
@@ -8,6 +8,7 @@ pub mod environment;
mod formatter;
mod java_runtime;
mod kotlin_bridge;
pub mod mcp;
pub mod notebook;
pub mod ops;
mod phpstan;
@@ -54,6 +55,8 @@ pub struct HostConfig {
}
pub struct Host {
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
pub trust: trust::TrustStore,
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
@@ -112,6 +115,8 @@ impl Host {
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
let host = Self {
mcp_reports: Default::default(),
mcp_requests: Default::default(),
trust: trust::TrustStore::load(&config_dir, &data_dir),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
proxy_session_password: Mutex::default(),
@@ -219,6 +224,8 @@ impl Host {
/// so the window can open another project at once. Returns the thread that
/// finishes shutting down its workers.
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
self.mcp_reports.forget_window(window);
self.mcp_requests.forget_window(window);
self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
+448
View File
@@ -0,0 +1,448 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/PatchToolset.kt, FileToolset.kt), Apache-2.0.
//! Changing the project and the editor: new files, patches, formatting and imports,
//! rename, open editors.
//!
//! Text changes go through the document layer: an open editor's buffer is
//! patched in place and remains unsaved. Other files are opened transiently,
//! saved and closed. Saves record Local History like the IDE's own edits.
mod diff;
mod format;
mod patch;
mod rename;
use crate::Project;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::event::McpUiRequest;
use ide_core::{DocId, FileEdit, IdeError, IdeResult, Pos, Range, TextEdit, WorkspaceEdit};
use patch::FileOp;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
pub fn tools() -> Vec<Tool> {
vec![create_new_file(), apply_patch(), format::reformat_file(), rename::rename_refactoring(), open_file_in_editor()]
}
/// What tools without a result value answer (JetBrains renders `Unit` so).
const SUCCESS: &str = "[success]";
// --- the document layer ----------------------------------------------------------
/// Whether the file is open with unsaved changes.
fn is_dirty(project: &Project, path: &Path) -> bool {
project.documents.by_path(path).is_some_and(|d| project.documents.is_dirty(d).unwrap_or(false))
}
async fn create_or_replace(call: &Call, path: PathBuf, text: Option<String>, overwrite: bool) -> IdeResult<()> {
let project = call.project.clone();
let edit = call
.host
.blocking(move || {
let mut files = Vec::new();
if path.exists() {
if !overwrite {
return Err(IdeError::invalid("File already exists"));
}
if let Some(text) = text {
let info = project.documents.open(&path)?;
let result: IdeResult<()> = (|| {
let snap = project.documents.text_snapshot(info.id)?;
let doc = ide_text::Document::new(&snap.text);
let edits = doc
.diff(&text)
.changes
.into_iter()
.map(|c| TextEdit {
range: Range::new(doc.offset_to_pos(c.from), doc.offset_to_pos(c.to)),
new_text: c.insert,
})
.collect();
files.push(FileEdit::Edit {
path: path.into(),
edits,
version: Some(
i32::try_from(snap.version)
.map_err(|_| IdeError::invalid("document version is out of range"))?,
),
});
Ok(())
})();
project.documents.close(info.id);
result?;
}
} else {
files.push(FileEdit::Create { path: path.clone().into(), overwrite: false });
if let Some(text) = text {
files.push(FileEdit::Edit {
path: path.into(),
edits: vec![TextEdit { range: Range::point(Pos::new(0, 0)), new_text: text }],
version: None,
});
}
}
Ok(WorkspaceEdit { files, preconditions: Vec::new() })
})
.await?;
if edit.files.is_empty() {
return Ok(());
}
let edit = crate::ops::lsp::prepare_workspace_edit(&call.host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&call.host, call.window(), edit).await
}
/// The open document of `path`, or a transient one (`true`: close it after use).
fn open_document(project: &Project, path: &Path) -> IdeResult<(DocId, bool)> {
match project.documents.by_path(path) {
Some(doc) => Ok((doc, false)),
None => Ok((project.documents.open(path)?.id, true)),
}
}
fn normalize_newlines(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
/// The range of a whole text.
fn whole_range(text: &str) -> Range {
let count = text.split('\n').count();
let last = text.rsplit('\n').next().unwrap_or("");
Range::new(Pos::new(0, 0), Pos::new(count.saturating_sub(1) as u32, last.encode_utf16().count() as u32))
}
/// Whether two paths name the same file (language servers report canonical paths; on a
/// case-insensitive file system `a.txt` and `A.txt` are one file).
fn same_file(a: &Path, b: &Path) -> bool {
if a == b || matches!((dunce::canonicalize(a), dunce::canonicalize(b)), (Ok(x), Ok(y)) if x == y) {
return true;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
return x.dev() == y.dev() && x.ino() == y.ino();
}
}
false
}
/// The text edits of `edit` for `path`.
fn edits_for(edit: &WorkspaceEdit, path: &Path) -> Vec<TextEdit> {
edit.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path: p, edits, .. } if same_file(p, path) => Some(edits.clone()),
_ => None,
})
.flatten()
.collect()
}
/// Re-reads the open, unmodified documents of `paths` whose files changed on disk, before
/// a tool reads or changes them: an agent that just wrote a file itself must not have
/// that write undone from a stale buffer because the file watcher has not caught up yet
/// (JetBrains refreshes the VFS first). Documents with unsaved changes are left alone.
fn refresh_from_disk(project: &Project, paths: &[PathBuf]) {
project.documents.on_disk_changed(paths);
}
/// Project files a tool works on: `files` resolved inside the project, existing,
/// regular, without duplicates (the first spelling is kept), changeable
/// ([`Call::check_writable`]) and with their open documents up to date with the disk.
async fn requested_files(call: &Call) -> IdeResult<Vec<(String, PathBuf)>> {
let files = call.str_list("files")?;
if files.is_empty() {
return Err(IdeError::invalid("files must contain at least one path"));
}
let mut out: Vec<(String, PathBuf)> = Vec::new();
for raw in files {
let raw = raw.trim();
if raw.is_empty() {
return Err(IdeError::invalid("files must not contain blank paths"));
}
let path = call.resolve_inside(raw)?;
if !path.exists() {
return Err(IdeError::not_found(format!("File not found: {raw}")));
}
if !path.is_file() {
return Err(IdeError::invalid(format!("Not a file: {raw}")));
}
if !out.iter().any(|(_, p)| *p == path) {
out.push((raw.to_owned(), path));
}
}
for (_, path) in &out {
call.check_writable(path).await?;
}
let (project, paths) = (call.project().clone(), out.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>());
call.host()
.blocking(move || {
refresh_from_disk(&project, &paths);
Ok(())
})
.await?;
Ok(out)
}
// --- create_new_file ----------------------------------------------------------------
fn create_new_file() -> Tool {
Tool::new(
"create_new_file",
"Creates a new file at the specified path within the project directory and optionally populates it with \
text if provided.
Use this tool to generate new files in your project structure.
Note: Creates any necessary parent directories automatically",
|call| async move {
let raw = call.str("pathInProject")?;
let path = call.resolve_writable(&raw).await?;
let text = call.opt_str("text")?.map(|t| normalize_newlines(&t));
let overwrite = call.flag("overwrite", false)?;
let cannot =
|e: &dyn std::fmt::Display| IdeError::invalid(format!("Can't create file: {}: {e}", path.display()));
if path.is_dir() {
return Err(cannot(&"it is a directory"));
}
if path.exists() && !overwrite {
return Err(IdeError::new(
ide_core::error::ErrorKind::AlreadyExists,
format!("File already exists: {raw}. Specify 'overwrite=true' to overwrite it"),
));
}
create_or_replace(&call, path, text, overwrite).await?;
Ok(Output::text(SUCCESS))
},
)
.write()
.param(Param::required(
"pathInProject",
ParamType::String,
"Path where the file should be created relative to the project root",
))
.param(Param::optional("text", ParamType::String, "Content to write into the new file"))
.param(Param::optional(
"overwrite",
ParamType::Boolean,
"Whether to overwrite an existing file if exists. If false, an exception is thrown in case of a conflict.",
))
}
// --- apply_patch --------------------------------------------------------------------
fn apply_patch() -> Tool {
Tool::new(
"apply_patch",
"Apply a patch using the Codex apply_patch format or unified git diff format.
Supports Add, Delete, and Update operations with optional Move to path for updates.
Paths must stay inside the project directory.",
|call| async move {
let input = [call.opt_str("input")?, call.opt_str("patch")?]
.into_iter()
.flatten()
.find(|s| !s.trim().is_empty())
.ok_or_else(|| IdeError::invalid("input must be a non-empty string"))?;
let ops = patch::parse(&input).map_err(IdeError::invalid)?;
let total = ops.iter().filter(|op| !matches!(op, FileOp::Skip { .. })).count();
let mut applied = 0usize;
let mut errors = Vec::new();
let mut notes = Vec::new();
// Each operation stands alone, as in JetBrains: a failed one leaves the others applied.
for op in ops {
if let FileOp::Skip { path, note } = op {
notes.push(format!("{path}: {note}"));
continue;
}
let path = op.path().to_owned();
match apply_op(&call, op, &mut notes).await {
Ok(()) => applied += 1,
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
Err(e) => errors.push(format!("{path}: {}", e.message)),
}
}
let mut out = format!("{applied} out of {total} operations applied.");
if !errors.is_empty() {
let _ = write!(
out,
" {} operations failed to be applied due to errors:\n{}",
errors.len(),
errors.join("\n")
);
}
if !notes.is_empty() {
out.push_str("\nNotes:");
for n in notes {
let _ = write!(out, "\n- {n}");
}
}
Ok(Output::text(out))
},
)
.write()
.param(Param::optional(
"input",
ParamType::String,
"Patch text in the apply_patch format or unified git diff format.",
))
.param(Param::optional(
"patch",
ParamType::String,
"Alias of `input` for compatibility with clients that send `{patch: ...}`.",
))
}
/// Applies one operation of a patch; its checks come first, so a failed operation changes nothing.
async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult<()> {
let host = call.host();
let project = call.project().clone();
match op {
// Noted by the caller.
FileOp::Skip { .. } => Ok(()),
FileOp::Add { path: raw, text } => {
let path = call.resolve_writable(&raw).await?;
if path.exists() {
return Err(IdeError::invalid(format!("File already exists: {raw}")));
}
create_or_replace(call, path, Some(text), false).await
}
FileOp::Delete { path: raw } => {
let path = call.resolve_writable(&raw).await?;
if !path.is_file() {
return Err(IdeError::not_found(format!("File not found: {raw}")));
}
if is_dirty(&project, &path) {
return Err(IdeError::invalid("cannot delete a file with unsaved editor changes"));
}
let edit = WorkspaceEdit {
files: vec![FileEdit::Delete { path: path.into(), recursive: false }],
preconditions: Vec::new(),
};
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await
}
FileOp::Update { path: first, move_to, hunks, or } => {
// A plain diff's other names count when its `+++` path does not exist.
let mut raw = first.clone();
let mut path = call.resolve_inside(&raw)?;
for other in &or {
if path.exists() {
break;
}
let candidate = call.resolve_inside(other)?;
if candidate.exists() {
(raw, path) = (other.clone(), candidate);
}
}
if !path.exists() {
let tried = if or.is_empty() { String::new() } else { format!(" (also tried {})", or.join(", ")) };
return Err(IdeError::not_found(format!("File not found: {first}{tried}")));
}
if !path.is_file() {
return Err(IdeError::invalid(format!("Path is not a file: {raw}")));
}
if !hunks.is_empty() || move_to.is_some() {
call.check_writable(&path).await?;
}
let to = match &move_to {
Some(m) => {
let to = call.resolve_writable(m).await?;
// A move that changes only the case on a case-insensitive file system finds itself.
if to != path && to.exists() && !same_file(&to, &path) {
return Err(IdeError::new(
ide_core::error::ErrorKind::AlreadyExists,
format!("File already exists: {m}"),
));
}
Some(to).filter(|t| *t != path)
}
None => None,
};
let shown = call.display(&path);
let (p, target) = (project.clone(), path.clone());
let (mut edit, fuzzy, dirty) = host
.blocking(move || {
refresh_from_disk(&p, std::slice::from_ref(&target));
let info = p.documents.open(&target)?;
let result = (|| {
let snap = p.documents.text_snapshot(info.id)?;
let dirty = p.documents.is_dirty(info.id)?;
let applied = patch::apply(&snap.text, &hunks).map_err(IdeError::invalid)?;
let text = ide_text::Document::new(&snap.text);
let edits = text
.diff(&applied.text)
.changes
.into_iter()
.map(|c| TextEdit {
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
new_text: c.insert,
})
.collect();
Ok((
WorkspaceEdit {
files: vec![FileEdit::Edit {
path: target.clone().into(),
edits,
version: Some(
i32::try_from(snap.version)
.map_err(|_| IdeError::invalid("document version is out of range"))?,
),
}],
preconditions: Vec::new(),
},
applied.fuzzy,
dirty,
))
})();
p.documents.close(info.id);
result
})
.await?;
if let Some(to) = to {
edit.files.push(FileEdit::Rename { from: path.into(), to: to.into() });
}
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await?;
if dirty {
notes.push(format!("{shown}: patched the unsaved editor buffer"));
}
for (hunk, fuzz) in fuzzy {
notes.push(format!("{shown}: hunk {hunk} matched only {}", fuzz.describe()));
}
Ok(())
}
}
}
// --- the editor ------------------------------------------------------------------------
fn open_file_in_editor() -> Tool {
Tool::new(
"open_file_in_editor",
"Opens the specified file in the IDE editor.
Requires a filePath parameter containing the path to the file to open.
The file path can be absolute or relative to the project root.
Optionally puts the caret at a 1-based line and column.",
|call| async move {
let raw = call.str("filePath")?;
// Opening shows the file to the user: outside the project the user agrees first.
let path = call.resolve_readable(&raw).await?;
if !path.is_file() {
return Err(IdeError::not_found(format!("File {raw} doesn't exist or can't be opened")));
}
let positive = |name: &str| -> IdeResult<Option<u32>> {
match call.opt_i64(name)? {
None => Ok(None),
Some(n) if n < 1 || n > u32::MAX as i64 => {
Err(IdeError::invalid(format!("`{name}` is 1-based and must be at least 1")))
}
Some(n) => Ok(Some(n as u32)),
}
};
let (line, column) = (positive("line")?, positive("column")?);
call.ask(McpUiRequest::OpenFile { path: path.into(), line, column }).await?;
Ok(Output::text(SUCCESS))
},
)
.write()
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::optional("line", ParamType::Integer, "1-based line to put the caret on"))
.param(Param::optional("column", ParamType::Integer, "1-based column on that line"))
}
+380
View File
@@ -0,0 +1,380 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/UnifiedDiffSupport.kt), Apache-2.0.
//! The unified diff tools answer with, so an agent sees what the IDE changed
//! without reading the files again. Capped, so a large change cannot flood the
//! agent's context.
/// Unchanged lines kept around each change, as in a standard unified diff.
const CONTEXT_LINES: usize = 3;
/// The default cap on diff lines.
pub const MAX_DIFF_LINES: usize = 200;
/// The smallest useful hunk: the `@@` header line and one line under it.
const MIN_HUNK_LINES: usize = 2;
/// Beyond this many differing lines (after the common head and tail) the
/// diff is not computed: the file is reported as too large to diff.
const MAX_EDIT_DISTANCE: usize = 2000;
/// One file a tool processed: the requested path, the text before and after.
/// `None` texts are reported as unavailable, never as "nothing changed".
pub struct ChangedFileText {
pub path: String,
pub before: Option<String>,
pub after: Option<String>,
}
/// A unified diff of the changes in `files`, at most `max_lines` diff lines;
/// `None` when nothing changed. A file goes in whole, or the footer names it.
pub fn build_unified_diff(files: &[ChangedFileText], max_lines: usize) -> Option<String> {
let mut lines: Vec<String> = Vec::new();
let mut omitted_files = Vec::new();
let mut omitted_lines = 0usize;
for file in files {
let Some(diff) = file_diff(file) else { continue };
// A header with no hunk under it helps nobody.
if !lines.is_empty() && max_lines.saturating_sub(lines.len()) < diff.header.len() + MIN_HUNK_LINES {
omitted_files.push(diff.path.clone());
omitted_lines += diff.line_count();
continue;
}
lines.extend(diff.header.iter().cloned());
for (index, hunk) in diff.hunks.iter().enumerate() {
let room = max_lines as isize - lines.len() as isize - 1;
if room < 1 && index > 0 {
omitted_lines += diff.hunks[index..].iter().map(Hunk::line_count).sum::<usize>();
break;
}
// The first hunk always keeps one line, so its file header never stands alone.
let body = room.max(1) as usize;
lines.extend(hunk.render(body));
omitted_lines += hunk.body.len().saturating_sub(body);
}
}
if lines.is_empty() {
return None;
}
let mut out = lines.join("\n");
if omitted_lines > 0 {
out.push_str(&format!("\n… and {omitted_lines} more diff line(s) omitted."));
}
if !omitted_files.is_empty() {
out.push_str(&format!("\n… The diff leaves out these file(s): {}.", omitted_files.join(", ")));
}
Some(out)
}
struct FileDiff {
path: String,
header: Vec<String>,
hunks: Vec<Hunk>,
}
impl FileDiff {
fn line_count(&self) -> usize {
self.header.len() + self.hunks.iter().map(Hunk::line_count).sum::<usize>()
}
}
/// One hunk: where it starts on each side (0-based), and its `-`, `+` and context lines.
struct Hunk {
start0: usize,
start1: usize,
body: Vec<String>,
}
impl Hunk {
fn line_count(&self) -> usize {
self.body.len() + 1
}
/// The hunk with at most `max_body` body lines; the header counts the lines it prints.
fn render(&self, max_body: usize) -> Vec<String> {
let printed = &self.body[..self.body.len().min(max_body)];
let count0 = printed.iter().filter(|l| l.starts_with(' ') || l.starts_with('-')).count();
let count1 = printed.iter().filter(|l| l.starts_with(' ') || l.starts_with('+')).count();
// An empty range names the line before it, so it keeps the 0-based value.
let start = |s: usize, count: usize| if count == 0 { s } else { s + 1 };
let mut out =
vec![format!("@@ -{},{count0} +{},{count1} @@", start(self.start0, count0), start(self.start1, count1))];
out.extend(printed.iter().cloned());
out
}
}
/// A line of the text, and whether it ends without a line separator (the last line).
type DiffLine<'a> = (&'a str, bool);
fn file_diff(file: &ChangedFileText) -> Option<FileDiff> {
let path = file.path.replace('\\', "/");
let header = vec![format!("--- a/{path}"), format!("+++ b/{path}")];
let note = |text: &str| {
let mut header = header.clone();
header.push(format!("({text})"));
Some(FileDiff { path: path.clone(), header, hunks: Vec::new() })
};
let (Some(before), Some(after)) = (&file.before, &file.after) else {
return note(
"the content of this file is not available, so the diff cannot report what changed; read the file again",
);
};
if before == after {
return None;
}
let old = split_lines(before);
let new = split_lines(after);
let Some(changes) = changes(&old, &new) else { return note("this file changed, but it is too large to diff") };
// The texts differ but the lines are equal: only the line separators changed.
if changes.is_empty() {
return note("only the line separators of this file changed");
}
let hunks = group(&changes).into_iter().map(|g| build_hunk(g, &old, &new)).collect();
Some(FileDiff { path, header, hunks })
}
/// The lines a diff compares: no extra empty line after a final separator; a
/// last line without one is marked, so a change of the final separator alone counts.
fn split_lines(text: &str) -> Vec<DiffLine<'_>> {
if text.is_empty() {
return Vec::new();
}
let ends_with_newline = text.ends_with('\n');
let mut lines: Vec<DiffLine> = text.split('\n').map(|l| (l.strip_suffix('\r').unwrap_or(l), false)).collect();
if ends_with_newline {
lines.pop();
} else if let Some(last) = lines.last_mut() {
last.1 = true;
}
lines
}
/// A run of changed lines: at `line0` in the old text `deleted` lines, at `line1` in the new one `inserted`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Change {
line0: usize,
line1: usize,
deleted: usize,
inserted: usize,
}
/// The changes from `a` to `b` (Myers' algorithm), `None` when they differ too much.
fn changes(a: &[DiffLine], b: &[DiffLine]) -> Option<Vec<Change>> {
let head = a.iter().zip(b).take_while(|(x, y)| x == y).count();
let tail = a[head..].iter().rev().zip(b[head..].iter().rev()).take_while(|(x, y)| x == y).count();
let (a_mid, b_mid) = (&a[head..a.len() - tail], &b[head..b.len() - tail]);
let script = myers(a_mid, b_mid)?;
// `script`: per step, true = equal (both advance), Some side otherwise.
let mut out: Vec<Change> = Vec::new();
let (mut i, mut j) = (head, head);
for step in script {
match step {
Step::Equal => {
i += 1;
j += 1;
continue;
}
Step::Delete => {
match out.last_mut() {
Some(c) if c.line0 + c.deleted == i && c.line1 + c.inserted == j => c.deleted += 1,
_ => out.push(Change { line0: i, line1: j, deleted: 1, inserted: 0 }),
}
i += 1;
}
Step::Insert => {
match out.last_mut() {
Some(c) if c.line0 + c.deleted == i && c.line1 + c.inserted == j => c.inserted += 1,
_ => out.push(Change { line0: i, line1: j, deleted: 0, inserted: 1 }),
}
j += 1;
}
}
}
Some(out)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Step {
Equal,
Delete,
Insert,
}
/// The shortest edit script from `a` to `b`, deletions before insertions in each run.
fn myers(a: &[DiffLine], b: &[DiffLine]) -> Option<Vec<Step>> {
let (n, m) = (a.len() as isize, b.len() as isize);
let max = (n + m) as usize;
let limit = max.min(MAX_EDIT_DISTANCE) as isize;
let off = max as isize + 1;
let mut v = vec![0isize; 2 * max + 3];
// V after each round d, for diagonals -d..=d.
let mut trace: Vec<Vec<isize>> = Vec::new();
let mut end = None;
'rounds: for d in 0..=limit {
for k in (-d..=d).step_by(2) {
let at = |k: isize| (off + k) as usize;
let mut x =
if k == -d || (k != d && v[at(k - 1)] < v[at(k + 1)]) { v[at(k + 1)] } else { v[at(k - 1)] + 1 };
let mut y = x - k;
while x < n && y < m && a[x as usize] == b[y as usize] {
x += 1;
y += 1;
}
v[at(k)] = x;
if x >= n && y >= m {
trace.push(v[at(-d)..=at(d)].to_vec());
end = Some(d);
break 'rounds;
}
}
trace.push(v[(off - d) as usize..=(off + d) as usize].to_vec());
}
let d_end = end?;
let mut steps = Vec::new();
let (mut x, mut y) = (n, m);
for d in (1..=d_end).rev() {
let prev = &trace[(d - 1) as usize];
let get = |k: isize| prev[(k + d - 1) as usize];
let k = x - y;
let insert = k == -d || (k != d && get(k - 1) < get(k + 1));
let prev_k = if insert { k + 1 } else { k - 1 };
let prev_x = get(prev_k);
let prev_y = prev_x - prev_k;
let (mid_x, mid_y) = if insert { (prev_x, prev_y + 1) } else { (prev_x + 1, prev_y) };
while x > mid_x && y > mid_y {
steps.push(Step::Equal);
x -= 1;
y -= 1;
}
steps.push(if insert { Step::Insert } else { Step::Delete });
(x, y) = (prev_x, prev_y);
}
steps.extend(std::iter::repeat_n(Step::Equal, x as usize));
steps.reverse();
// Within a run of changes, deletions first (as `-` lines precede `+` lines).
let mut i = 0;
while i < steps.len() {
if steps[i] == Step::Equal {
i += 1;
continue;
}
let end = steps[i..].iter().position(|s| *s == Step::Equal).map_or(steps.len(), |p| i + p);
steps[i..end].sort_by_key(|s| *s == Step::Insert);
i = end;
}
Some(steps)
}
/// Changes whose context lines touch share a hunk.
fn group(changes: &[Change]) -> Vec<&[Change]> {
let mut out: Vec<&[Change]> = Vec::new();
let mut start = 0;
for i in 1..changes.len() {
let prev = changes[i - 1];
if changes[i].line0 - (prev.line0 + prev.deleted) > CONTEXT_LINES * 2 {
out.push(&changes[start..i]);
start = i;
}
}
if !changes.is_empty() {
out.push(&changes[start..]);
}
out
}
fn build_hunk(group: &[Change], old: &[DiffLine], new: &[DiffLine]) -> Hunk {
let (first, last) = (group[0], group[group.len() - 1]);
// One clamp for both sides, so the two starts in the header stay in step.
let leading = CONTEXT_LINES.min(first.line0).min(first.line1);
let (start0, start1) = (first.line0 - leading, first.line1 - leading);
let (end0, end1) = (last.line0 + last.deleted, last.line1 + last.inserted);
let trailing = CONTEXT_LINES.min(old.len() - end0).min(new.len() - end1);
let mut body = Vec::new();
let push = |body: &mut Vec<String>, prefix: char, (text, no_newline): DiffLine| {
body.push(format!("{prefix}{text}"));
if no_newline {
body.push("\\ No newline at end of file".into());
}
};
let mut line0 = start0;
for c in group {
while line0 < c.line0 {
push(&mut body, ' ', old[line0]);
line0 += 1;
}
for &l in &old[c.line0..c.line0 + c.deleted] {
push(&mut body, '-', l);
}
for &l in &new[c.line1..c.line1 + c.inserted] {
push(&mut body, '+', l);
}
line0 = c.line0 + c.deleted;
}
while line0 < end0 + trailing {
push(&mut body, ' ', old[line0]);
line0 += 1;
}
Hunk { start0, start1, body }
}
#[cfg(test)]
mod tests {
use super::*;
fn diff(before: &str, after: &str) -> Option<String> {
build_unified_diff(
&[ChangedFileText { path: "src/A.java".into(), before: Some(before.into()), after: Some(after.into()) }],
MAX_DIFF_LINES,
)
}
#[test]
fn diffs_a_reformat_like_jetbrains() {
let out = diff("public class DiffTarget {\nint a;\n}\n", "public class DiffTarget {\n int a;\n}\n").unwrap();
assert_eq!(
out,
"--- a/src/A.java\n+++ b/src/A.java\n@@ -1,3 +1,3 @@\n public class DiffTarget {\n-int a;\n+ int a;\n }"
);
assert_eq!(diff("same\n", "same\n"), None);
}
#[test]
fn splits_far_changes_into_hunks_and_marks_the_final_newline() {
let before: String = (1..=20).map(|i| format!("{i}\n")).collect();
let after: String = (1..=20)
.map(|i| match i {
2 => "two\n".to_owned(),
19 => "nineteen\n".to_owned(),
i => format!("{i}\n"),
})
.collect();
let out = diff(&before, &after).unwrap();
assert!(out.contains("@@ -1,5 +1,5 @@\n 1\n-2\n+two\n 3\n 4\n 5\n"), "{out}");
assert!(out.contains("@@ -16,5 +16,5 @@\n 16\n 17\n 18\n-19\n+nineteen\n 20"), "{out}");
let out = diff("a\nb", "a\nb\n").unwrap();
assert!(out.ends_with("@@ -1,2 +1,2 @@\n a\n-b\n\\ No newline at end of file\n+b"), "{out}");
let out = diff("a\r\n", "a\n").unwrap();
assert!(out.ends_with("(only the line separators of this file changed)"), "{out}");
let out = diff("", "x\n").unwrap();
assert!(out.ends_with("@@ -0,0 +1,1 @@\n+x"), "{out}");
let out = diff("import a;\n\nclass X {}\n", "\nclass X {}\n").unwrap();
assert!(out.ends_with("@@ -1,3 +1,2 @@\n-import a;\n \n class X {}"), "{out}");
}
#[test]
fn caps_the_diff_and_names_what_it_leaves_out() {
let before: String = (0..300).map(|i| format!("{i}\n")).collect();
let after: String = (0..300).map(|i| format!("x{i}\n")).collect();
let files = [
ChangedFileText { path: "a.txt".into(), before: Some(before.clone()), after: Some(after.clone()) },
ChangedFileText { path: "b.txt".into(), before: Some(before), after: Some(after) },
ChangedFileText { path: "c.bin".into(), before: None, after: None },
];
let out = build_unified_diff(&files, 50).unwrap();
assert_eq!(out.lines().filter(|l| !l.starts_with('…')).count(), 50, "{out}");
// 553 lines of a.txt's hunk, 603 of b.txt, 3 of c.bin's note.
assert!(out.contains("… and 1159 more diff line(s) omitted."), "{out}");
assert!(out.ends_with("… The diff leaves out these file(s): b.txt, c.bin."), "{out}");
// The cut hunk's header counts what it prints.
assert!(out.contains("@@ -1,47 +0,0 @@"), "{out}");
}
}
+199
View File
@@ -0,0 +1,199 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/FormattingToolset.kt), Apache-2.0.
//! `reformat_file` and `optimize_imports` with the language servers: document
//! formatting and the `source.organizeImports` code action. Both answer with a
//! unified diff of what changed, or `ok`.
//!
//! JetBrains always has a formatter and an import optimizer; here a file whose
//! language server offers none is reported after the diff (the whole call fails
//! only when no file could be processed).
use super::diff::{ChangedFileText, MAX_DIFF_LINES, build_unified_diff};
use super::{edits_for, open_document, requested_files, whole_range};
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{DocId, IdeError, IdeResult};
use std::path::Path;
const ORGANIZE_IMPORTS: &str = "source.organizeImports";
pub(super) fn reformat_file() -> Tool {
Tool::new(
"reformat_file",
"Reformats the specified files in the IDE.
Use this tool to apply code formatting rules to files identified by their project-relative paths.
Returns a unified diff of the changes that the formatter applied, or \"ok\" when the files already match the code \
style.
The diff shows the formatter changes only. It never repeats the edit that you applied yourself.
The diff stops at 200 lines. A footer names the file(s) that it leaves out.
To remove unused imports without reformatting, use optimize_imports.",
|call| async move {
let organize = call.flag("optimizeImports", false)?;
run(&call, Job { organize, format: true }).await
},
)
.write()
.param(Param::required(
"files",
ParamType::StringList,
"List of project-relative files to reformat. Duplicate paths are ignored after normalization.",
))
.param(Param::optional(
"optimizeImports",
ParamType::Boolean,
"If true, also remove unused imports and order the remaining ones before reformatting.",
))
}
#[derive(Clone, Copy)]
struct Job {
organize: bool,
format: bool,
}
async fn run(call: &Call, job: Job) -> IdeResult<Output> {
let files = requested_files(call).await?;
let mut changed = Vec::new();
let mut problems = Vec::new();
for (raw, path) in &files {
match process(call, path, job).await {
Ok(outcome) => {
changed.push(ChangedFileText {
path: raw.clone(),
before: Some(outcome.before),
after: Some(outcome.after),
});
problems.extend(outcome.notes.into_iter().map(|n| format!("{raw}: {n}")));
}
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
Err(e) => {
let what = if job.format { "not formatted" } else { "imports not optimized" };
problems.push(format!("{raw}: {what}: {}", e.message));
}
}
}
if changed.is_empty() {
return Err(IdeError::unavailable(problems.join("\n")));
}
let mut out = build_unified_diff(&changed, MAX_DIFF_LINES).unwrap_or_else(|| "ok".into());
if !problems.is_empty() {
out.push_str("\n\n");
out.push_str(&problems.join("\n"));
}
Ok(Output::text(out))
}
struct Outcome {
before: String,
after: String,
/// What went wrong without failing the file (imports not optimized before a reformat).
notes: Vec<String>,
}
/// Runs the job on one file's document (opened transiently when it is not open) and saves it.
async fn process(call: &Call, path: &Path, job: Job) -> IdeResult<Outcome> {
let host = call.host();
let project = call.project().clone();
let (p, target) = (project.clone(), path.to_path_buf());
let (doc, transient) = host.blocking(move || open_document(&p, &target)).await?;
let result = async {
let info = project.documents.snapshot(doc)?;
if info.read_only {
return Err(IdeError::invalid("the file is read-only"));
}
let before = project.documents.text_snapshot(doc)?.text;
let mut notes = Vec::new();
if job.organize {
match organize_imports(call, doc, path).await {
Ok(()) => {}
Err(e) if job.format && e.kind != ide_core::error::ErrorKind::Cancelled => {
notes.push(format!("imports not optimized: {}", e.message));
}
Err(e) => return Err(e),
}
}
if job.format {
format(call, doc, &info).await?;
}
let p = project.clone();
let after = host
.blocking(move || {
if !info.dirty && p.documents.is_dirty(doc)? {
p.documents.save(doc)?;
}
Ok(p.documents.text_snapshot(doc)?.text)
})
.await?;
Ok(Outcome { before, after, notes })
}
.await;
if transient {
project.documents.close(doc);
}
result
}
/// Formats the whole document with its language server; indentation from EditorConfig, else the settings.
async fn format(call: &Call, doc: DocId, info: &ide_workspace::DocumentInfo) -> IdeResult<()> {
let host = call.host();
let project = call.project().clone();
let editor = project.settings.effective().editor;
let ec = info.editorconfig.as_ref();
let insert_spaces = match ec.and_then(|e| e.indent_style) {
Some(style) => style != ide_workspace::editorconfig::IndentStyle::Tab,
None => editor.insert_spaces,
};
let tab_size = ec.and_then(|e| e.indent_size.or(e.tab_width)).unwrap_or(editor.tab_size);
let options = ide_lsp::types::FormatOptions { tab_size, insert_spaces, on_save: false, tool: None };
let version = project.documents.text_snapshot(doc)?.version;
let edits = tokio::select! {
r = crate::ops::lsp::lsp_format(&host, call.window(), doc, None, options) => r?,
_ = call.cancel.cancelled() => return Err(IdeError::cancelled()),
};
host.blocking(move || {
if project.documents.text_snapshot(doc)?.version != version {
return Err(IdeError::unavailable("the file changed while it was being formatted; try again"));
}
project.documents.apply_edits(doc, &edits).map(|_| ())
})
.await
}
/// Runs the `source.organizeImports` code action of the document's language server.
/// Nothing offered: nothing to do, unless the server says it has no such action at all.
pub(super) async fn organize_imports(call: &Call, doc: DocId, path: &Path) -> IdeResult<()> {
let host = call.host();
let window = call.window();
let text = call.project().documents.text_snapshot(doc)?.text;
let request = crate::mcp::tool::request_id();
let only = vec![ORGANIZE_IMPORTS.to_owned()];
let query = tokio::select! {
r = crate::ops::lsp::lsp_code_action_query(&host, window, request, doc, whole_range(&text), only) => r?,
_ = call.cancel.cancelled() => {
host.cancel_request(request);
return Err(IdeError::cancelled());
}
};
let organizes = |kind: &str| kind == ORGANIZE_IMPORTS || kind.starts_with("source.organizeImports.");
let action = query
.actions
.into_iter()
.filter(|a| a.disabled_reason.is_none() && a.kind.as_deref().is_some_and(organizes))
.max_by_key(|a| a.is_preferred);
let Some(action) = action else {
let offered = query.advertised_kinds.is_none_or(|kinds| kinds.iter().any(|k| k == "source" || organizes(k)));
return if offered {
Ok(())
} else {
Err(IdeError::unavailable("the language server of this file offers no organize imports action"))
};
};
let action = crate::ops::lsp::lsp_resolve_code_action(&host, window, doc, action).await?;
match &action.edit {
Some(edit) => {
let edits = edits_for(edit, path);
let project = call.project().clone();
host.blocking(move || project.documents.apply_edits(doc, &edits).map(|_| ())).await
}
// The server applies the change itself (`workspace/applyEdit` during the command).
None => crate::ops::lsp::lsp_apply_code_action(&host, window, doc, action).await,
}
}
+949
View File
@@ -0,0 +1,949 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/PatchApplyEngine.kt), Apache-2.0.
//! Patches as AI agents write them: the Codex `apply_patch` format (with the
//! strict `@@` pair blocks some models write) and unified (git) diffs, bare or
//! inside `*** Begin Patch`. Parsing and applying hunks to a text are pure, so
//! they are tested here; the tool in `edit.rs` resolves paths and writes through
//! the documents.
//!
//! Beyond JetBrains: stacked `@@` anchors, unified hunk line numbers as hints,
//! `\ No newline at end of file`, plain `diff -u` / `diff -ru` names, and errors
//! that show where a hunk went wrong.
use std::fmt::Write as _;
/// One file of a patch, paths as written in it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileOp {
Add {
path: String,
text: String,
},
Delete {
path: String,
},
/// `or` lists other paths the file may have, tried in order when `path` does
/// not exist (a plain diff's `---` name, or its names without their first
/// directory, as `patch -p1` reads `diff -ru old/ new/`).
Update {
path: String,
move_to: Option<String>,
hunks: Vec<Hunk>,
or: Vec<String>,
},
/// A git section the tool does not apply (a copy): skipped with `note`, the others go on.
Skip {
path: String,
note: String,
},
}
impl FileOp {
pub fn path(&self) -> &str {
match self {
FileOp::Add { path, .. }
| FileOp::Delete { path }
| FileOp::Update { path, .. }
| FileOp::Skip { path, .. } => path,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Hunk {
/// `@@ <line>` anchors: found in order before the hunk's lines.
pub anchors: Vec<String>,
/// `@@ -a,b +c,d @@`: the 1-based start in the old file, a hint where to look.
pub old_start: Option<usize>,
pub lines: Vec<Line>,
/// `*** End of File`: the lines end the file.
pub at_eof: bool,
/// `\ No newline at end of file` after the old / new side.
pub old_no_newline: bool,
pub new_no_newline: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Line {
Context(String),
Remove(String),
Add(String),
}
impl Hunk {
fn old_lines(&self) -> Vec<&str> {
self.lines
.iter()
.filter_map(|l| match l {
Line::Context(s) | Line::Remove(s) => Some(s.as_str()),
Line::Add(_) => None,
})
.collect()
}
fn new_lines(&self) -> Vec<&str> {
self.lines
.iter()
.filter_map(|l| match l {
Line::Context(s) | Line::Add(s) => Some(s.as_str()),
Line::Remove(_) => None,
})
.collect()
}
/// How the hunk is named in errors.
fn label(&self, n: usize) -> String {
match (self.anchors.last(), self.old_start) {
(Some(a), _) => format!("hunk {n} (@@ {a})"),
(None, Some(s)) => format!("hunk {n} (at line {s})"),
_ => format!("hunk {n}"),
}
}
}
const BEGIN_PATCH: &str = "*** Begin Patch";
const END_PATCH: &str = "*** End Patch";
const ADD: &str = "*** Add File: ";
const DELETE: &str = "*** Delete File: ";
const UPDATE: &str = "*** Update File: ";
const MOVE: &str = "*** Move to: ";
const END_OF_FILE: &str = "*** End of File";
const DIFF_GIT: &str = "diff --git ";
/// Parses either format: `*** Begin Patch` … `*** End Patch` (holding the Codex
/// format or a git diff), else a bare unified diff.
pub fn parse(input: &str) -> Result<Vec<FileOp>, String> {
let lines: Vec<&str> = input.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let ops = match lines.iter().position(|l| l.trim() == BEGIN_PATCH) {
Some(begin) => {
let end = lines[begin + 1..]
.iter()
.position(|l| l.trim() == END_PATCH)
.map(|e| begin + 1 + e)
.ok_or("patch must include *** End Patch (the patch looks truncated)")?;
let body = &lines[begin + 1..end];
// `--- x` also removes a `-- x` line (SQL, Lua comments): only a body without
// Codex headers is read as a diff.
let codex = body.iter().any(|l| {
let t = l.trim_start();
t.starts_with(ADD) || t.starts_with(DELETE) || t.starts_with(UPDATE)
});
if !codex && looks_like_git_diff(body) {
parse_unified(body, begin + 1)?
} else {
parse_codex(body, begin + 1)?
}
}
None if looks_like_git_diff(&lines) => parse_unified(&lines, 0)?,
None => {
return Err("patch must include *** Begin Patch (or be a unified diff with `--- a/path` and \
`+++ b/path` headers)"
.into());
}
};
if ops.is_empty() {
return Err("patch did not contain any operations".into());
}
Ok(ops)
}
fn looks_like_git_diff(lines: &[&str]) -> bool {
let mut markers = false;
for line in lines {
let t = line.trim_start();
if t.starts_with(DIFF_GIT) || t.starts_with("rename from ") || t.starts_with("rename to ") {
return true;
}
markers |= line.starts_with("--- ") || line.starts_with("+++ ");
}
markers
}
/// Refuses paths with control characters, or with `\n`-style escapes a model wrote
/// instead of a real path.
fn ensure_safe_path(path: &str, label: &str) -> Result<(), String> {
let control = path.chars().any(|c| c.is_ascii_control());
let escaped = ["\\n", "\\r", "\\t"].iter().any(|e| path.contains(e));
if control || escaped {
Err(format!("{label} path contains control characters or escape sequences"))
} else {
Ok(())
}
}
// --- the Codex format -----------------------------------------------------------
fn is_diff_prefix(c: char) -> bool {
matches!(c, ' ' | '+' | '-')
}
/// `*** Add File:` and the like (not `*** End of File`, which belongs to a hunk).
fn is_patch_header(line: &str) -> bool {
match line.chars().next() {
None => false,
Some(c) if is_diff_prefix(c) => false,
Some(_) => {
let t = line.trim_start();
t.starts_with("*** ") && t.trim_end() != END_OF_FILE
}
}
}
fn is_hunk_header(line: &str) -> bool {
line.chars().next().is_some_and(|c| !is_diff_prefix(c)) && line.trim_start().starts_with("@@")
}
fn op_path(rest: &str, label: &str) -> Result<String, String> {
let path = rest.trim();
if path.is_empty() {
return Err(format!("{label} requires a path"));
}
ensure_safe_path(path, label)?;
Ok(path.to_owned())
}
/// `lines[start..]` is the body after `*** Begin Patch`; `offset` numbers lines in errors.
fn parse_codex(body: &[&str], offset: usize) -> Result<Vec<FileOp>, String> {
let mut ops = Vec::new();
let mut i = 0;
let n = |i: usize| i + offset + 1;
while i < body.len() {
let line = body[i];
let header = line.trim_start();
if let Some(rest) = header.strip_prefix(ADD) {
let path = op_path(rest, "Add File")?;
i += 1;
// (text, written with `+`): blank separator lines before the next header are dropped.
let mut content: Vec<(&str, bool)> = Vec::new();
while let Some(&l) = body.get(i).filter(|l| !is_patch_header(l)) {
match l.strip_prefix('+') {
Some(text) => content.push((text, true)),
None if l.trim().is_empty() => content.push(("", false)),
None => return Err(format!("Add File lines must start with + (line {}: `{l}`)", n(i))),
}
i += 1;
}
while content.last().is_some_and(|(_, explicit)| !explicit) {
content.pop();
}
let mut text = content.iter().map(|(t, _)| *t).collect::<Vec<_>>().join("\n");
if !content.is_empty() {
text.push('\n');
}
ops.push(FileOp::Add { path, text });
} else if let Some(rest) = header.strip_prefix(DELETE) {
ops.push(FileOp::Delete { path: op_path(rest, "Delete File")? });
i += 1;
} else if let Some(rest) = header.strip_prefix(UPDATE) {
let path = op_path(rest, "Update File")?;
i += 1;
let mut move_to = None;
if let Some(rest) =
body.get(i).filter(|l| is_patch_header(l)).and_then(|l| l.trim_start().strip_prefix(MOVE))
{
move_to = Some(op_path(rest, "Move to")?);
i += 1;
}
let mut hunks = Vec::new();
while i < body.len() && !is_patch_header(body[i]) {
if body[i].trim().is_empty() {
i += 1;
continue;
}
hunks.push(parse_codex_hunk(body, &mut i, hunks.is_empty(), offset)?);
}
// A move alone needs no hunk (JetBrains requires one).
if hunks.is_empty() && move_to.is_none() {
return Err("Update File requires at least one hunk".into());
}
ops.push(FileOp::Update { path, move_to, hunks, or: Vec::new() });
} else if line.trim().is_empty() {
i += 1;
} else {
return Err(format!("Unexpected patch line: {line}"));
}
}
Ok(ops)
}
fn parse_codex_hunk(body: &[&str], i: &mut usize, first: bool, offset: usize) -> Result<Hunk, String> {
let mut hunk = Hunk::default();
let mut bare = false;
let mut headed = false;
// Several `@@` lines narrow the place down, as in Codex.
while let Some(&line) = body.get(*i).filter(|l| is_hunk_header(l)) {
let trimmed = line.trim();
match parse_range_header(trimmed) {
Some((start, ..)) => hunk.old_start = Some(start),
None => {
let text = trimmed.trim_start_matches('@').trim();
// `@@ fn main() @@` repeats the marker at the end sometimes.
let text = text.trim_end_matches('@').trim_end();
if !text.is_empty() {
hunk.anchors.push(text.to_owned());
}
}
}
bare = trimmed == "@@";
headed = true;
*i += 1;
}
let n = |i: usize| i + offset + 1;
if !headed {
let line = body[*i];
if !first || !line.chars().next().is_none_or(is_diff_prefix) {
return Err(format!("Expected @@ hunk header (line {}: `{line}`)", n(*i)));
}
}
let is_strict_start =
|l: &str| !l.is_empty() && !is_patch_header(l) && !is_hunk_header(l) && !l.starts_with(is_diff_prefix);
if bare && body.get(*i).is_some_and(|l| is_strict_start(l)) {
return parse_strict_pair(body, i);
}
// Blank lines at the end of a hunk are usually separators, not context.
let mut implicit_blanks = 0usize;
while let Some(&line) = body.get(*i).filter(|l| !is_hunk_header(l) && !is_patch_header(l)) {
if line.trim_end() == END_OF_FILE {
hunk.at_eof = true;
*i += 1;
break;
}
let parsed = match line.chars().next() {
None => Line::Context(String::new()),
Some(' ') => Line::Context(line[1..].to_owned()),
Some('-') => Line::Remove(line[1..].to_owned()),
Some('+') => Line::Add(line[1..].to_owned()),
Some(_) if hunk.lines.is_empty() => {
return Err(format!("Hunk lines must start with space, +, or - (line {}: `{line}`)", n(*i)));
}
Some(_) => break,
};
implicit_blanks = if line.is_empty() { implicit_blanks + 1 } else { 0 };
hunk.lines.push(parsed);
*i += 1;
}
hunk.lines.truncate(hunk.lines.len() - implicit_blanks);
if hunk.lines.is_empty() {
return Err("Empty hunk in Update File".into());
}
Ok(hunk)
}
/// `@@`, the old lines, `@@`, the new lines: whole lines without `-` / `+` prefixes.
fn parse_strict_pair(body: &[&str], i: &mut usize) -> Result<Hunk, String> {
let mut old = Vec::new();
let mut closed = false;
while let Some(&line) = body.get(*i).filter(|l| !is_patch_header(l)) {
*i += 1;
if line.trim() == "@@" {
closed = true;
break;
}
old.push(line);
}
if !closed {
return Err("Strict @@ pair hunk requires second @@ delimiter".into());
}
let mut new = Vec::new();
while let Some(&line) = body.get(*i).filter(|l| !is_patch_header(l) && !is_hunk_header(l)) {
new.push(line);
*i += 1;
}
while new.last().is_some_and(|l| l.trim().is_empty()) {
new.pop();
}
if old.is_empty() && new.is_empty() {
return Err("Empty hunk in Update File".into());
}
let lines = old
.into_iter()
.map(|l| Line::Remove(l.to_owned()))
.chain(new.into_iter().map(|l| Line::Add(l.to_owned())))
.collect();
Ok(Hunk { lines, ..Hunk::default() })
}
// --- unified diffs -------------------------------------------------------------
#[derive(Default)]
struct Section {
git_old: Option<String>,
git_new: Option<String>,
/// `---` / `+++` seen; `None` inside is `/dev/null`.
old: Option<Option<String>>,
new: Option<Option<String>>,
new_file: bool,
deleted: bool,
rename_from: Option<String>,
rename_to: Option<String>,
/// `old mode` / `new mode`: a permission change, which the tool leaves alone.
mode_change: bool,
/// `copy from` / `copy to`.
copy_from: Option<String>,
copy_to: Option<String>,
hunks: Vec<Hunk>,
line: usize,
}
fn is_file_start(lines: &[&str], i: usize) -> bool {
let l = lines[i];
l.starts_with(DIFF_GIT)
|| (l.starts_with("--- ") && lines.get(i + 1).is_some_and(|n| n.starts_with("+++ ")) && l != "--- ")
}
/// `offset` numbers lines in errors (the diff may sit inside `*** Begin Patch`).
fn parse_unified(lines: &[&str], offset: usize) -> Result<Vec<FileOp>, String> {
let mut ops = Vec::new();
let mut i = 0;
let mut section: Option<Section> = None;
while i < lines.len() {
let line = lines[i];
let n = i + offset + 1;
if is_file_start(lines, i) && (line.starts_with(DIFF_GIT) || section.as_ref().is_none_or(|s| s.old.is_some())) {
if let Some(s) = section.take() {
ops.push(finish_section(s)?);
}
let mut s = Section { line: n, ..Section::default() };
if let Some(rest) = line.strip_prefix(DIFF_GIT) {
(s.git_old, s.git_new) = split_git_paths(rest);
i += 1;
}
section = Some(s);
continue;
}
let Some(s) = section.as_mut() else {
if line.starts_with("@@ ") {
return Err(format!("line {n}: a hunk without a `--- a/path` / `+++ b/path` header before it"));
}
// Text before the first file (a commit message, `Index:` lines).
i += 1;
continue;
};
let trimmed = line.trim_start();
if let Some(p) = line.strip_prefix("--- ") {
s.old = Some(diff_path(p, "a/"));
} else if let Some(p) = line.strip_prefix("+++ ") {
s.new = Some(diff_path(p, "b/"));
} else if trimmed.starts_with("new file mode") {
s.new_file = true;
} else if trimmed.starts_with("deleted file mode") {
s.deleted = true;
} else if let Some(p) = trimmed.strip_prefix("rename from ") {
s.rename_from = Some(rename_path(p)?);
} else if let Some(p) = trimmed.strip_prefix("rename to ") {
s.rename_to = Some(rename_path(p)?);
} else if trimmed.starts_with("Binary files ") || trimmed == "GIT binary patch" {
return Err(format!("Binary git patch is not supported (line {n})"));
} else if let Some(p) = trimmed.strip_prefix("copy from ") {
s.copy_from = Some(rename_path(p)?);
} else if let Some(p) = trimmed.strip_prefix("copy to ") {
s.copy_to = Some(rename_path(p)?);
} else if trimmed.starts_with("old mode ") || trimmed.starts_with("new mode ") {
s.mode_change = true;
} else if line.starts_with("@@") {
if s.old.is_none() && s.new.is_none() && s.git_old.is_none() {
return Err(format!("line {n}: a hunk without a `--- a/path` / `+++ b/path` header before it"));
}
i += 1;
let hunk = parse_unified_hunk(lines, &mut i, line, n)?;
s.hunks.push(hunk);
continue;
} else if !s.hunks.is_empty() && (line.starts_with('+') || line.starts_with('-') || line.starts_with(' ')) {
// Lines after a hunk that its counts left out (`-- ` ends `git format-patch` output).
if line != "-- " {
return Err(format!(
"line {n}: `{line}` follows a hunk but is not part of it; check the hunk's counts"
));
}
if let Some(s) = section.take() {
ops.push(finish_section(s)?);
}
}
// Other extended headers (`index`, modes, similarity) need nothing.
i += 1;
}
if let Some(s) = section {
ops.push(finish_section(s)?);
}
Ok(ops)
}
/// `@@ -a,b +c,d @@`: hunk lines until the counts are used up; lines beyond
/// counts that are too small are taken too, and a bare `@@` reads to the next header.
fn parse_unified_hunk(lines: &[&str], i: &mut usize, header: &str, n: usize) -> Result<Hunk, String> {
let counts = parse_range_header(header);
if counts.is_none() && header.starts_with("@@ -") {
return Err(format!("line {n}: cannot read the hunk header `{header}`"));
}
let mut hunk = Hunk { old_start: counts.map(|c| c.0), ..Hunk::default() };
let (mut old_left, mut new_left) = counts.map_or((usize::MAX, usize::MAX), |c| (c.1, c.2));
let mut implicit_blanks = 0usize;
let first = *i;
while let Some(&line) = lines.get(*i) {
let done = old_left == 0 && new_left == 0;
if line.starts_with("@@") || is_file_start(lines, *i) {
break;
}
if line.trim_end() == END_OF_FILE {
hunk.at_eof = true;
*i += 1;
break;
}
if line.starts_with('\\') {
match hunk.lines.last() {
Some(Line::Remove(_)) => hunk.old_no_newline = true,
Some(Line::Add(_)) => hunk.new_no_newline = true,
Some(Line::Context(_)) => {
hunk.old_no_newline = true;
hunk.new_no_newline = true;
}
None => {}
}
*i += 1;
continue;
}
let parsed = match line.chars().next() {
_ if done && (line.is_empty() || line == "-- ") => break,
Some(' ') => Line::Context(line[1..].to_owned()),
Some('-') => Line::Remove(line[1..].to_owned()),
Some('+') => Line::Add(line[1..].to_owned()),
// Editors strip the space of empty context lines.
None => Line::Context(String::new()),
Some(_) if hunk.lines.is_empty() => {
return Err(format!(
"Hunk lines must start with space, +, or - (line {}: `{line}`)",
n + 1 + *i - first
));
}
Some(_) => break,
};
match parsed {
Line::Context(_) => {
old_left = old_left.saturating_sub(1);
new_left = new_left.saturating_sub(1);
}
Line::Remove(_) => old_left = old_left.saturating_sub(1),
Line::Add(_) => new_left = new_left.saturating_sub(1),
}
implicit_blanks = if line.is_empty() { implicit_blanks + 1 } else { 0 };
hunk.lines.push(parsed);
*i += 1;
}
if counts.is_none() {
hunk.lines.truncate(hunk.lines.len() - implicit_blanks);
}
if hunk.lines.is_empty() {
return Err(format!("Empty hunk in Update File (line {n}: `{header}`)"));
}
Ok(hunk)
}
/// (old start, old count, new count) of `@@ -a[,b] +c[,d] @@`; extra `@`s (`@@@`) are accepted.
fn parse_range_header(header: &str) -> Option<(usize, usize, usize)> {
let rest = header.trim_start_matches('@').trim_start().strip_prefix('-')?;
let (ranges, _) = rest.split_once(" @@").unwrap_or((rest.trim_end_matches('@'), ""));
let (old, new) = ranges.split_once(" +")?;
let range = |r: &str| -> Option<(usize, usize)> {
match r.trim().split_once(',') {
Some((s, c)) => Some((s.parse().ok()?, c.parse().ok()?)),
None => Some((r.trim().parse().ok()?, 1)),
}
};
let (start, old_count) = range(old)?;
let (_, new_count) = range(new)?;
Some((start, old_count, new_count))
}
/// The two paths of `diff --git a/x b/y` (ambiguous with spaces: equal halves win).
fn split_git_paths(rest: &str) -> (Option<String>, Option<String>) {
let rest = rest.trim();
if let Some((a, b)) = rest.strip_prefix('"').and_then(|r| r.split_once("\" ")) {
return (Some(strip_prefix(&unquote(&format!("\"{a}\"")), "a/")), Some(strip_prefix(&unquote(b), "b/")));
}
let splits: Vec<usize> = rest.match_indices(" b/").map(|(i, _)| i).collect();
let pick = splits.iter().find(|&&i| rest[..i].strip_prefix("a/") == Some(&rest[i + 3..])).or(splits.first());
match pick {
Some(&i) => (Some(strip_prefix(&rest[..i], "a/")), Some(rest[i + 3..].to_owned())),
None => (None, None),
}
}
/// A `---` / `+++` path: `None` for `/dev/null`; timestamps and the `a/` / `b/` prefix removed.
fn diff_path(raw: &str, prefix: &str) -> Option<String> {
let raw = raw.split('\t').next().unwrap_or(raw).trim_end();
let path = unquote(raw);
if path == "/dev/null" {
return None;
}
Some(strip_prefix(&path, prefix))
}
fn rename_path(raw: &str) -> Result<String, String> {
let path = unquote(raw.trim());
if path.is_empty() { Err("Could not determine file path from git diff".into()) } else { Ok(path) }
}
fn strip_prefix(path: &str, prefix: &str) -> String {
path.strip_prefix(prefix).unwrap_or(path).to_owned()
}
/// Git quotes paths with special characters: `"a/x \"y\""`.
fn unquote(s: &str) -> String {
let Some(inner) = s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else { return s.to_owned() };
let mut out = String::new();
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some(c) => out.push(c),
None => {}
}
}
out
}
fn finish_section(s: Section) -> Result<FileOp, String> {
const NO_PATH: &str = "Could not determine file path from git diff";
let n = s.line;
let old = match &s.old {
Some(p) => p.clone(),
None => s.rename_from.clone().or_else(|| s.git_old.clone()),
};
let new = match &s.new {
Some(p) => p.clone(),
None => s.rename_to.clone().or_else(|| s.git_new.clone()),
};
if s.copy_from.is_some() || s.copy_to.is_some() {
// Git writes a copy only with `-C`; JetBrains reads it as a change of the source.
let to = s.copy_to.clone().or(new).ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
let from = s.copy_from.clone().or(old).unwrap_or_default();
let note = format!(
"the copy of {from} to {to} was skipped: copies are not supported; add the new file with `*** Add File` \
instead"
);
return Ok(FileOp::Skip { path: to, note });
}
if s.new_file || (s.old.as_ref().is_some_and(|o| o.is_none())) {
let path = new.ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
ensure_safe_path(&path, "Add File")?;
let mut lines = Vec::new();
let mut no_newline = false;
for h in &s.hunks {
for l in &h.lines {
match l {
Line::Add(t) => lines.push(t.as_str()),
_ => return Err(format!("`{path}` is a new file, but its hunk has context or removed lines")),
}
}
no_newline |= h.new_no_newline;
}
let mut text = lines.join("\n");
if !lines.is_empty() && !no_newline {
text.push('\n');
}
return Ok(FileOp::Add { path, text });
}
if s.deleted || (s.new.as_ref().is_some_and(|n| n.is_none())) {
let path = old.ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
ensure_safe_path(&path, "Delete File")?;
return Ok(FileOp::Delete { path });
}
// Only git's rename headers say a file moves: plain diffs often name the sides
// differently (`--- a.txt.orig` / `+++ a.txt`, `diff -ru old/ new/`).
if s.rename_from.is_some() || s.rename_to.is_some() {
let path = old.or(new.clone()).ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
ensure_safe_path(&path, "Update File")?;
let move_to = new.filter(|p| *p != path);
if let Some(m) = &move_to {
ensure_safe_path(m, "Move to")?;
}
if s.hunks.is_empty() && move_to.is_none() && !s.mode_change {
return Err(format!("line {n}: `{path}` has no changes"));
}
return Ok(FileOp::Update { path, move_to, hunks: s.hunks, or: Vec::new() });
}
let path = new.clone().or(old.clone()).ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
ensure_safe_path(&path, "Update File")?;
if s.hunks.is_empty() {
// Only the file mode changes: nothing to do to the text, as in JetBrains.
if s.mode_change {
return Ok(FileOp::Update { path, move_to: None, hunks: Vec::new(), or: Vec::new() });
}
return Err(format!("line {n}: `{path}` has no changes"));
}
let below_first_dir = |p: &Option<String>| p.as_ref().and_then(|p| p.split_once('/')).map(|(_, r)| r.to_owned());
let mut or: Vec<String> = Vec::new();
let others =
if old == new { [None, None, None] } else { [old.clone(), below_first_dir(&new), below_first_dir(&old)] };
for p in others.into_iter().flatten() {
if !p.is_empty() && p != path && !or.contains(&p) {
or.push(p);
}
}
Ok(FileOp::Update { path, move_to: None, hunks: s.hunks, or })
}
// --- applying -------------------------------------------------------------------
/// How exactly a hunk's lines matched the file, tried in this order.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Fuzz {
Exact,
TrailingWhitespace,
Whitespace,
/// Whitespace, and typographic dashes, quotes and spaces as their ASCII forms.
Punctuation,
}
impl Fuzz {
fn same(self, a: &str, b: &str) -> bool {
match self {
Fuzz::Exact => a == b,
Fuzz::TrailingWhitespace => a.trim_end() == b.trim_end(),
Fuzz::Whitespace => a.trim() == b.trim(),
Fuzz::Punctuation => a.trim().chars().map(normalize_char).eq(b.trim().chars().map(normalize_char)),
}
}
pub fn describe(self) -> &'static str {
match self {
Fuzz::Exact => "exactly",
Fuzz::TrailingWhitespace => "ignoring trailing whitespace",
Fuzz::Whitespace => "ignoring indentation and whitespace",
Fuzz::Punctuation => "ignoring whitespace and typographic punctuation (dashes, quotes, spaces)",
}
}
}
fn normalize_char(c: char) -> char {
match c {
'\u{2010}'..='\u{2015}' | '\u{2212}' => '-',
'\u{2018}'..='\u{201B}' => '\'',
'\u{201C}'..='\u{201F}' => '"',
'\u{00A0}' | '\u{2002}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' => ' ',
c => c,
}
}
const LEVELS: [Fuzz; 4] = [Fuzz::Exact, Fuzz::TrailingWhitespace, Fuzz::Whitespace, Fuzz::Punctuation];
/// The result of [`apply`]: the new text and the hunks that matched only loosely.
#[derive(Debug, PartialEq, Eq)]
pub struct Applied {
pub text: String,
/// (1-based hunk, how it matched) for hunks that did not match exactly.
pub fuzzy: Vec<(usize, Fuzz)>,
}
/// Applies `hunks` in order to `text` (`\n` line endings). A hunk is searched
/// after the previous one, then (unless it ends the file) from the top. Errors
/// name the hunk and the lines that were not found.
pub fn apply(text: &str, hunks: &[Hunk]) -> Result<Applied, String> {
let trailing_newline = text.ends_with('\n');
let mut lines: Vec<String> = if text.is_empty() {
Vec::new()
} else {
let mut l: Vec<String> = text.split('\n').map(str::to_owned).collect();
if trailing_newline {
l.pop();
}
l
};
let original_len = lines.len();
let mut cursor = 0usize;
let mut fuzzy = Vec::new();
let mut ends_without_newline = None;
for (index, hunk) in hunks.iter().enumerate() {
let n = index + 1;
let file: Vec<&str> = lines.iter().map(String::as_str).collect();
let mut anchor_line = None;
for anchor in &hunk.anchors {
let find =
|from: usize| LEVELS.iter().find_map(|level| (from..file.len()).find(|&i| level.same(file[i], anchor)));
let found = find(cursor).or_else(|| find(0)).ok_or_else(|| {
format!(
"Hunk context not found: {}: cannot find the line `{anchor}` its `@@` header names",
hunk.label(n)
)
})?;
anchor_line = Some(found);
cursor = found + 1;
}
// Line numbers of a diff count the original file: shift them by what earlier hunks added.
let shift = |s: usize| (s as isize + lines.len() as isize - original_len as isize).max(0) as usize;
let mut old = hunk.old_lines();
if old.is_empty() {
let at = if !hunk.anchors.is_empty() {
cursor
} else if hunk.at_eof {
file.len()
} else {
// `@@ -5,0 +6,2 @@` inserts after line 5; without a position, at the end.
hunk.old_start.map_or(file.len(), |s| shift(s).min(file.len()))
};
let new: Vec<String> = hunk.new_lines().into_iter().map(str::to_owned).collect();
cursor = at + new.len();
lines.splice(at..at, new);
continue;
}
let hint = hunk.old_start.map(|s| shift(s.saturating_sub(1)));
// Models often repeat the `@@` line as the hunk's first context line: then the
// block starts at the anchor rather than after it.
let find = |old: &[&str]| {
find_block(&file, old, cursor, hint, hunk.at_eof)
.or_else(|| anchor_line.and_then(|a| find_block(&file, old, a, hint, hunk.at_eof)))
.or_else(|| (cursor > 0 && !hunk.at_eof).then(|| find_block(&file, old, 0, hint, false)).flatten())
};
let mut found = find(&old);
// A blank last line in the patch that the file does not have (Codex does the same).
if found.is_none() && old.last() == Some(&"") {
old.pop();
if !old.is_empty() {
found = find(&old);
}
}
let Some((at, level)) = found else {
return Err(mismatch(&file, &old, hint, &hunk.label(n), hunk.at_eof));
};
if level != Fuzz::Exact {
fuzzy.push((n, level));
}
if at + old.len() == file.len() {
if hunk.new_no_newline {
ends_without_newline = Some(true);
} else if hunk.old_no_newline {
ends_without_newline = Some(false);
}
}
// Context lines keep the file's spelling (a loose match must not re-indent them).
let mut new = Vec::new();
let mut matched = 0usize;
for line in &hunk.lines {
match line {
Line::Context(_) | Line::Remove(_) if matched >= old.len() => {}
Line::Context(_) => {
new.push(file[at + matched].to_owned());
matched += 1;
}
Line::Remove(_) => matched += 1,
Line::Add(s) => new.push(s.clone()),
}
}
let removed = old.len();
cursor = at + new.len();
lines.splice(at..at + removed, new);
}
let mut result = lines.join("\n");
let newline = match ends_without_newline {
Some(no) => !no,
None => trailing_newline || text.is_empty(),
};
if newline && !lines.is_empty() {
result.push('\n');
}
Ok(Applied { text: result, fuzzy })
}
/// Where `old` occurs at or after `from`, as exactly as possible; with a hint,
/// the occurrence nearest to it. A hunk that ends the file must match its end.
fn find_block(lines: &[&str], old: &[&str], from: usize, hint: Option<usize>, at_eof: bool) -> Option<(usize, Fuzz)> {
if old.len() > lines.len() {
return None;
}
let last = lines.len() - old.len();
let matches = |at: usize, level: Fuzz| old.iter().zip(&lines[at..]).all(|(o, l)| level.same(l, o));
for level in LEVELS {
if at_eof {
if last >= from && matches(last, level) {
return Some((last, level));
}
continue;
}
let mut best: Option<usize> = None;
for at in from..=last {
if !matches(at, level) {
continue;
}
match hint {
None => return Some((at, level)),
Some(h) => {
if best.is_none_or(|b| at.abs_diff(h) < b.abs_diff(h)) {
best = Some(at);
}
}
}
}
if let Some(at) = best {
return Some((at, level));
}
}
None
}
/// The error for lines that were not found, with the closest partial match.
fn mismatch(lines: &[&str], old: &[&str], hint: Option<usize>, label: &str, at_eof: bool) -> String {
let mut msg = format!("Hunk context not found: {label} does not apply: these lines were not found:\n");
for l in old {
let _ = writeln!(msg, " {l}");
}
// The start with the longest run of matching lines (ignoring whitespace).
let mut best: Option<(usize, usize)> = None;
for at in 0..lines.len() {
let run = old.iter().zip(&lines[at..]).take_while(|(o, l)| Fuzz::Punctuation.same(o, l)).count();
let better = match best {
None => run > 0,
Some((b, r)) => run > r || (run == r && hint.is_some_and(|h| at.abs_diff(h) < b.abs_diff(h))),
};
if better {
best = Some((at, run));
}
}
match best {
Some((at, run)) if run < old.len() => {
let actual = lines.get(at + run).map_or("(end of file)".to_owned(), |l| format!("`{l}`"));
let _ = write!(
msg,
"Closest match at line {}: line {} is {actual}, but the patch expects `{}`.",
at + 1,
at + run + 1,
old[run]
);
}
Some((at, _)) if at_eof => {
let _ = write!(
msg,
"They occur at line {}, but the hunk is marked `*** End of File` and they do not end the file.",
at + 1
);
}
Some((at, _)) => {
let _ = write!(msg, "They occur at line {}.", at + 1);
}
None => {
// A common mistake: only part of a line after `-` / ` `.
let partial = old
.iter()
.filter(|o| !o.trim().is_empty())
.find_map(|o| lines.iter().position(|l| l.contains(o.trim())).map(|i| (o.trim(), i)));
match partial {
Some((o, i)) => {
let _ = write!(
msg,
"Line {} contains `{o}` but is `{}`: patch lines must be whole lines, with their indentation.",
i + 1,
lines[i]
);
}
None => msg.push_str("No line of them is in the file; read the file again before patching it."),
}
}
}
msg
}
#[cfg(test)]
mod tests;
+584
View File
@@ -0,0 +1,584 @@
//! Parser and engine tests, including JetBrains' `PatchApplyEngineTest` cases.
use super::*;
fn update(ops: &[FileOp]) -> &[Hunk] {
match &ops[0] {
FileOp::Update { hunks, .. } => hunks,
other => panic!("not an update: {other:?}"),
}
}
fn apply_patch(text: &str, patch: &str) -> Result<String, String> {
let ops = parse(patch)?;
apply(text, update(&ops)).map(|a| a.text)
}
fn patch(lines: &[&str]) -> String {
lines.iter().map(|l| format!("{l}\n")).collect()
}
fn hunk(lines: &[(char, &str)], at_eof: bool) -> Hunk {
let lines = lines
.iter()
.map(|&(p, t)| match p {
'-' => Line::Remove(t.into()),
'+' => Line::Add(t.into()),
_ => Line::Context(t.into()),
})
.collect();
Hunk { lines, at_eof, ..Hunk::default() }
}
// --- Codex format -------------------------------------------------------
#[test]
fn parses_every_codex_operation() {
let ops = parse(
"*** Begin Patch\n*** Add File: new.txt\n+one\n+two\n*** Delete File: old.txt\n*** Update File: a.rs\n\
*** Move to: b.rs\n@@ fn main\n ctx\n-old\n+new\n*** End Patch\n",
)
.unwrap();
assert_eq!(
ops,
vec![
FileOp::Add { path: "new.txt".into(), text: "one\ntwo\n".into() },
FileOp::Delete { path: "old.txt".into() },
FileOp::Update {
path: "a.rs".into(),
move_to: Some("b.rs".into()),
hunks: vec![Hunk {
anchors: vec!["fn main".into()],
lines: vec![Line::Context("ctx".into()), Line::Remove("old".into()), Line::Add("new".into())],
..Hunk::default()
}],
or: vec![],
},
]
);
}
#[test]
fn codex_tolerates_wrappers_crlf_and_blank_separators() {
let ops = parse(
"apply_patch <<'EOF'\r\n*** Begin Patch\r\n*** Add File: x\r\n+a\r\n+\r\n\r\n*** Update File: y\r\n-a\r\n+b\r\n\r\n*** End Patch\r\nEOF",
)
.unwrap();
assert_eq!(ops[0], FileOp::Add { path: "x".into(), text: "a\n\n".into() });
assert_eq!(update(&ops[1..])[0].lines, vec![Line::Remove("a".into()), Line::Add("b".into())]);
// JetBrains: a heredoc around the patch.
let ops = parse(&patch(&["<<EOF", "*** Begin Patch", "*** Add File: notes.txt", "+alpha", "*** End Patch", "EOF"]))
.unwrap();
assert_eq!(ops, vec![FileOp::Add { path: "notes.txt".into(), text: "alpha\n".into() }]);
}
#[test]
fn codex_empty_add_and_move_only() {
let ops = parse("*** Begin Patch\n*** Add File: e\n*** Update File: a\n*** Move to: b\n*** End Patch").unwrap();
assert_eq!(ops[0], FileOp::Add { path: "e".into(), text: String::new() });
assert_eq!(ops[1], FileOp::Update { path: "a".into(), move_to: Some("b".into()), hunks: vec![], or: vec![] });
}
/// JetBrains `PatchApplyEngineTest`: the parse errors.
#[test]
fn codex_errors() {
let fails = |lines: &[&str], expected: &str| {
let e = parse(&patch(lines)).unwrap_err();
assert!(e.contains(expected), "expected `{expected}` in: {e}");
};
fails(&["oops"], "patch must include *** Begin Patch");
fails(&["*** Begin Patch", "*** Add File: notes.txt", "+alpha"], "patch must include *** End Patch");
fails(&["*** Begin Patch", "*** End Patch"], "patch did not contain any operations");
fails(&["*** Begin Patch", "oops", "*** End Patch"], "Unexpected patch line: oops");
fails(&["*** Begin Patch", "*** Add File: ", "*** End Patch"], "Add File requires a path");
fails(
&["*** Begin Patch", "*** Add File: notes.txt", "alpha", "*** End Patch"],
"Add File lines must start with +",
);
fails(
&["*** Begin Patch", "*** Add File: notes.txt", "+alpha", "beta", "*** End Patch"],
"Add File lines must start with + (line 4: `beta`)",
);
fails(&["*** Begin Patch", "*** Delete File: ", "*** End Patch"], "Delete File requires a path");
fails(&["*** Begin Patch", "*** Update File: ", "*** End Patch"], "Update File requires a path");
fails(
&["*** Begin Patch", "*** Update File: s.txt", "*** Move to: ", "*** End Patch"],
"Move to requires a path",
);
fails(&["*** Begin Patch", "*** Update File: s.txt", "*** End Patch"], "Update File requires at least one hunk");
fails(&["*** Begin Patch", "*** Update File: s.txt", "oops", "*** End Patch"], "Expected @@ hunk header");
fails(
&["*** Begin Patch", "*** Update File: s.txt", " alpha", "-beta", "+gamma", "oops", "*** End Patch"],
"Expected @@ hunk header",
);
fails(
&["*** Begin Patch", "*** Update File: s.txt", "@@ def sample()", "oops", "*** End Patch"],
"Hunk lines must start with space, +, or -",
);
fails(&["*** Begin Patch", "*** Update File: s.txt", "@@", "*** End Patch"], "Empty hunk in Update File");
fails(
&["*** Begin Patch", "*** Update File: s.txt", "@@", "## Goals", "*** End Patch"],
"Strict @@ pair hunk requires second @@ delimiter",
);
fails(
&["*** Begin Patch", "*** Delete File: bad\\npath.txt", "*** End Patch"],
"Delete File path contains control",
);
fails(
&["*** Begin Patch", "*** Add File: bad\\npath.txt", "+alpha", "*** End Patch"],
"Add File path contains control characters or escape sequences",
);
fails(&["*** Begin Patch", "*** Update File: bad\\npath.txt", "*** End Patch"], "Update File path contains");
fails(
&["*** Begin Patch", "*** Update File: s.txt", "*** Move to: bad\\npath.txt", "*** End Patch"],
"Move to path contains",
);
fails(&["*** Begin Patch", "*** Update File: a", "-x", "*** End of File", "-y", "*** End Patch"], "Expected @@");
}
#[test]
fn codex_hunks_split_on_markers_and_stack_anchors() {
let ops = parse(
"*** Begin Patch\n*** Update File: a\n@@ class A\n@@ def f():\n-x\n+y\n@@\n-p\n+q\n*** End of File\n*** End Patch",
)
.unwrap();
let hunks = update(&ops);
assert_eq!(hunks.len(), 2);
assert_eq!(hunks[0].anchors, vec!["class A".to_owned(), "def f():".to_owned()]);
assert!(!hunks[0].at_eof && hunks[1].at_eof);
assert!(hunks[1].anchors.is_empty());
}
#[test]
fn codex_headers_with_line_numbers_are_hints_not_anchors() {
for header in ["@@@ -48,6 +48,7 @@@", "@@ -48,6 +48,7 @@", "@@ -48,6 +48,7 @@ fn sample()"] {
let ops = parse(&patch(&["*** Begin Patch", "*** Update File: s", header, " a", "-b", "+c", "*** End Patch"]))
.unwrap();
let h = &update(&ops)[0];
assert!(h.anchors.is_empty(), "{header}: {:?}", h.anchors);
assert_eq!(h.old_start, Some(48), "{header}");
}
let ops =
parse(&patch(&["*** Begin Patch", "*** Update File: s", "@@ def sample()", " a", "*** End Patch"])).unwrap();
assert_eq!(update(&ops)[0].anchors, vec!["def sample()".to_owned()]);
// JetBrains `applyHunks works with unified-diff style hunk headers`.
let p = patch(&[
"*** Begin Patch",
"*** Update File: s",
"@@ -1,3 +1,4 @@",
" alpha",
"-beta",
"+BETA",
" gamma",
"+delta",
"*** End Patch",
]);
assert_eq!(apply_patch("alpha\nbeta\ngamma\n", &p).unwrap(), "alpha\nBETA\ngamma\ndelta\n");
}
#[test]
fn strict_pair_hunks() {
let p = patch(&[
"*** Begin Patch",
"*** Update File: s",
"@@",
"## Goals",
"- Keep old wording",
"@@",
"## Goals",
"- Keep new wording",
"*** End Patch",
]);
let ops = parse(&p).unwrap();
let h = &update(&ops)[0];
assert!(h.anchors.is_empty());
assert_eq!(
h.lines,
vec![
Line::Remove("## Goals".into()),
Line::Remove("- Keep old wording".into()),
Line::Add("## Goals".into()),
Line::Add("- Keep new wording".into()),
]
);
assert_eq!(apply_patch("# T\n## Goals\n- Keep old wording\n", &p).unwrap(), "# T\n## Goals\n- Keep new wording\n");
}
#[test]
fn applies_codex_hunks_with_anchors() {
let text = "class A:\n def f():\n return 1\nclass B:\n def f():\n return 1\n";
let patch = "*** Begin Patch\n*** Update File: a.py\n@@ class B:\n def f():\n- return 1\n+ return 2\n*** End Patch";
assert_eq!(
apply_patch(text, patch).unwrap(),
"class A:\n def f():\n return 1\nclass B:\n def f():\n return 2\n"
);
}
#[test]
fn applies_several_hunks_in_order() {
let text = "a\nb\nc\nd\ne\nf\n";
let patch = "*** Begin Patch\n*** Update File: x\n@@\n a\n-b\n+B\n@@\n e\n-f\n+F\n+G\n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "a\nB\nc\nd\ne\nF\nG\n");
}
#[test]
fn same_lines_twice_match_in_order() {
let text = "x\ny\nx\ny\n";
let patch = "*** Begin Patch\n*** Update File: f\n@@\n-x\n+1\n@@\n-x\n+2\n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "1\ny\n2\ny\n");
}
#[test]
fn end_of_file_hunks_must_end_the_file() {
let text = "end\nmid\nend\n";
let patch = "*** Begin Patch\n*** Update File: f\n@@\n-end\n+END\n*** End of File\n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "end\nmid\nEND\n");
let beta = hunk(&[('-', "beta"), ('+', "BETA")], true);
assert_eq!(apply("alpha\nbeta\n", std::slice::from_ref(&beta)).unwrap().text, "alpha\nBETA\n");
let e = apply("alpha\nbeta\ngamma\n", &[beta]).unwrap_err();
assert!(e.starts_with("Hunk context not found") && e.contains("do not end the file"), "{e}");
}
#[test]
fn hunks_out_of_order_are_found_from_the_top() {
let hunks = [hunk(&[('-', "one"), ('+', "ONE")], true), hunk(&[('-', "two"), ('+', "TWO")], false)];
assert_eq!(apply("one\ntwo\nthree\none\n", &hunks).unwrap().text, "one\nTWO\nthree\nONE\n");
let text = "fn a() {\n one();\n two();\n}\n";
let p =
"*** Begin Patch\n*** Update File: f\n@@\n- two();\n+ 2();\n@@\n- one();\n+ 1();\n*** End Patch";
assert_eq!(apply_patch(text, p).unwrap(), "fn a() {\n 1();\n 2();\n}\n");
}
#[test]
fn many_repeated_hunks_apply_deterministically() {
let hunks: Vec<Hunk> = (0..80).map(|i| hunk(&[('-', "alpha"), ('+', &format!("alpha-{i}"))], false)).collect();
let text = "alpha\n".repeat(600);
let expected: String = (0..80).map(|i| format!("alpha-{i}\n")).collect::<String>() + &"alpha\n".repeat(520);
assert_eq!(apply(&text, &hunks).unwrap().text, expected);
}
#[test]
fn accepts_the_anchor_repeated_as_context() {
let text = "package greet\n\nfunc prefix() string {\n\treturn \"Merhaba, \"\n}\n";
let patch = "*** Begin Patch\n*** Update File: f\n@@ func prefix() string {\n func prefix() string {\n-\treturn \"Merhaba, \"\n+\treturn \"Selam, \"\n }\n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "package greet\n\nfunc prefix() string {\n\treturn \"Selam, \"\n}\n");
}
#[test]
fn pure_additions_go_after_the_anchor_or_at_the_end() {
let text = "fn a() {}\nfn b() {}\n";
let after = "*** Begin Patch\n*** Update File: f\n@@ fn a() {}\n+fn a2() {}\n*** End Patch";
assert_eq!(apply_patch(text, after).unwrap(), "fn a() {}\nfn a2() {}\nfn b() {}\n");
let end = "*** Begin Patch\n*** Update File: f\n@@\n+fn c() {}\n*** End Patch";
assert_eq!(apply_patch(text, end).unwrap(), "fn a() {}\nfn b() {}\nfn c() {}\n");
}
#[test]
fn fuzz_levels_are_tried_in_order_and_reported() {
let text = "fn main() { \n let x = 1;\n}\n";
let ops =
parse("*** Begin Patch\n*** Update File: f\n@@\n fn main() {\n- let x = 1;\n+ let x = 2;\n*** End Patch")
.unwrap();
let a = apply(text, update(&ops)).unwrap();
assert_eq!(a.text, "fn main() { \n let x = 2;\n}\n");
assert_eq!(a.fuzzy, vec![(1, Fuzz::TrailingWhitespace)]);
let ops = parse("*** Begin Patch\n*** Update File: f\n@@\n-let x = 1;\n+ let y = 1;\n*** End Patch").unwrap();
let a = apply(text, update(&ops)).unwrap();
assert_eq!(a.text, "fn main() { \n let y = 1;\n}\n");
assert_eq!(a.fuzzy, vec![(1, Fuzz::Whitespace)]);
// JetBrains: typographic punctuation, exact matches first.
let typographic = "import asyncio # local import \u{2013} avoids top\u{2011}level dep";
let ascii = "import asyncio # local import - avoids top-level dep";
let a =
apply(&format!("{typographic}\n"), &[hunk(&[('-', ascii), ('+', "import asyncio # HELLO")], false)]).unwrap();
assert_eq!(a.text, "import asyncio # HELLO\n");
assert_eq!(a.fuzzy, vec![(1, Fuzz::Punctuation)]);
let both = [hunk(&[('-', ascii), ('+', "# FIRST")], false), hunk(&[('-', ascii), ('+', "# SECOND")], false)];
let text = format!("first\n{typographic}\n{typographic}\n");
assert_eq!(apply(&text, &both).unwrap().text, "first\n# FIRST\n# SECOND\n");
let text = format!("first\n{typographic}\n{ascii}\n");
let exact = apply(&text, &[hunk(&[('-', ascii), ('+', "# EXACT")], false)]).unwrap();
assert_eq!(exact.text, format!("first\n{typographic}\n# EXACT\n"));
}
#[test]
fn a_trailing_blank_context_line_is_optional() {
let text = "a\nb";
let patch = "*** Begin Patch\n*** Update File: f\n@@\n-b\n+c\n \n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "a\nc");
}
#[test]
fn keeps_a_missing_final_newline_and_handles_empty_files() {
assert_eq!(apply_patch("a\nb", "*** Begin Patch\n*** Update File: f\n-b\n+c\n*** End Patch").unwrap(), "a\nc");
assert_eq!(apply_patch("", "*** Begin Patch\n*** Update File: f\n+x\n*** End Patch").unwrap(), "x\n");
assert_eq!(apply_patch("a\n", "*** Begin Patch\n*** Update File: f\n-a\n*** End Patch").unwrap(), "");
let h = hunk(&[('-', "alpha"), ('+', "alpha2"), (' ', "beta")], false);
assert_eq!(apply("alpha \nbeta\n", &[h]).unwrap().text, "alpha2\nbeta\n");
}
#[test]
fn reports_where_a_hunk_went_wrong() {
let text = "fn a() {\n one();\n two();\n}\n";
let patch =
"*** Begin Patch\n*** Update File: f\n@@\n fn a() {\n one();\n- three();\n+ four();\n*** End Patch";
let e = apply_patch(text, patch).unwrap_err();
assert!(e.starts_with("Hunk context not found: hunk 1 does not apply"), "{e}");
assert!(e.contains(" fn a() {\n one();\n three();\n"), "{e}");
assert!(e.contains("Closest match at line 1: line 3 is ` two();`, but the patch expects ` three();`"), "{e}");
let e = apply_patch(text, "*** Begin Patch\n*** Update File: f\n@@ fn zzz\n-x\n*** End Patch").unwrap_err();
assert!(e.contains("hunk 1 (@@ fn zzz)") && e.contains("`fn zzz`"), "{e}");
let e = apply_patch(text, "*** Begin Patch\n*** Update File: f\n-nothing\n*** End Patch").unwrap_err();
assert!(e.contains("No line of them"), "{e}");
let e = apply_patch(text, "*** Begin Patch\n*** Update File: f\n-two\n+three\n*** End Patch").unwrap_err();
assert!(e.contains("Line 3 contains `two` but is ` two();`: patch lines must be whole lines"), "{e}");
}
// --- unified diffs ------------------------------------------------------
const GIT: &str = "diff --git a/src/lib.rs b/src/lib.rs
index 3b18e51..a9e0c2f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,3 @@ mod x;
fn a() {}
-fn b() {}
+fn b() { todo!() }
fn c() {}
@@ -10,2 +10,3 @@
fn j() {}
+fn k() {}
fn l() {}
diff --git a/new.txt b/new.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/new.txt
@@ -0,0 +1,2 @@
+hello
+world
diff --git a/gone.txt b/gone.txt
deleted file mode 100644
--- a/gone.txt
+++ /dev/null
@@ -1 +0,0 @@
-bye
diff --git a/old name.txt b/new name.txt
similarity index 90%
rename from old name.txt
rename to new name.txt
--- a/old name.txt
+++ b/new name.txt
@@ -1 +1 @@
-x
+y
";
#[test]
fn parses_git_diffs() {
let ops = parse(GIT).unwrap();
assert_eq!(ops.len(), 4);
match &ops[0] {
FileOp::Update { path, move_to: None, hunks, or } => {
assert_eq!(path, "src/lib.rs");
assert!(or.is_empty(), "{or:?}");
assert_eq!(hunks.len(), 2);
assert_eq!(hunks[0].old_start, Some(1));
assert_eq!(hunks[1].lines.len(), 3);
}
other => panic!("{other:?}"),
}
assert_eq!(ops[1], FileOp::Add { path: "new.txt".into(), text: "hello\nworld\n".into() });
assert_eq!(ops[2], FileOp::Delete { path: "gone.txt".into() });
assert!(matches!(&ops[3], FileOp::Update { path, move_to: Some(to), .. }
if path == "old name.txt" && to == "new name.txt"));
}
#[test]
fn parses_git_diffs_inside_begin_patch() {
let ops = parse(&patch(&[
"*** Begin Patch",
"diff --git a/s.txt b/s.txt",
"--- a/s.txt",
"+++ b/s.txt",
"@@ -1 +1 @@",
"-old",
"+new",
"*** End Patch",
]))
.unwrap();
assert!(
matches!(&ops[..], [FileOp::Update { path, move_to: None, hunks, .. }] if path == "s.txt" && hunks.len() == 1)
);
// `--- x` removing a `-- x` line keeps a Codex patch a Codex patch.
let ops =
parse("*** Begin Patch\n*** Update File: q.sql\n@@\n--- old comment\n+-- new comment\n*** End Patch").unwrap();
assert_eq!(update(&ops)[0].lines, vec![Line::Remove("-- old comment".into()), Line::Add("-- new comment".into())]);
}
#[test]
fn parses_plain_unified_diffs_and_pure_renames() {
let ops =
parse("--- a.txt\t2024-01-01 10:00:00\n+++ a.txt\t2024-01-02 10:00:00\n@@ -1,2 +1,2 @@\n-a\n+b\n c\n").unwrap();
assert!(
matches!(&ops[0], FileOp::Update { path, move_to: None, hunks, .. } if path == "a.txt" && hunks.len() == 1)
);
// Differently named sides are not a move: the `+++` path, else the others.
let ops = parse("--- a.txt.orig\n+++ a.txt\n@@ -1 +1 @@\n-a\n+b\n").unwrap();
assert!(matches!(&ops[0], FileOp::Update { path, move_to: None, or, .. }
if path == "a.txt" && or == &["a.txt.orig"]));
let ops = parse(
"diff -ru old/src/a.txt new/src/a.txt\n--- old/src/a.txt\t2024-01-01\n+++ new/src/a.txt\t2024-01-02\n@@ -1 +1 @@\n-a\n+b\n",
)
.unwrap();
assert!(matches!(&ops[0], FileOp::Update { path, move_to: None, or, .. }
if path == "new/src/a.txt" && or == &["old/src/a.txt", "src/a.txt"]));
let ops = parse("diff --git a/x.rs b/y/x.rs\nsimilarity index 100%\nrename from x.rs\nrename to y/x.rs\n").unwrap();
assert_eq!(
ops[0],
FileOp::Update { path: "x.rs".into(), move_to: Some("y/x.rs".into()), hunks: vec![], or: vec![] }
);
let ops = parse("diff --git a/e b/e\nnew file mode 100644\nindex 0000000..e69de29\n").unwrap();
assert_eq!(ops[0], FileOp::Add { path: "e".into(), text: String::new() });
let ops = parse("diff --git \"a/sp ace\" \"b/sp ace\"\ndeleted file mode 100644\n").unwrap();
assert_eq!(ops[0], FileOp::Delete { path: "sp ace".into() });
}
#[test]
fn unified_counts_are_tolerated() {
// Counts too large: the next header ends the hunk.
let ops = parse("--- a/f\n+++ b/f\n@@ -1,9 +1,9 @@\n-a\n+b\n--- a/g\n+++ b/g\n@@ -1 +1 @@\n-c\n+d\n").unwrap();
assert_eq!(ops.len(), 2);
assert_eq!(update(&ops)[0].lines.len(), 2);
// Counts too small: following hunk lines still belong to it.
let ops = parse("--- a/f\n+++ b/f\n@@ -1,1 +1,1 @@\n-a\n+b\n c\n").unwrap();
assert_eq!(update(&ops)[0].lines.len(), 3);
// A bare `@@` reads to the next header; empty lines are empty context.
let ops = parse("--- a/f\n+++ b/f\n@@\n x\n\n-y\n+z\n\n").unwrap();
assert_eq!(
update(&ops)[0].lines,
vec![Line::Context("x".into()), Line::Context(String::new()), Line::Remove("y".into()), Line::Add("z".into())]
);
// `git format-patch` signature.
let ops = parse("Subject: x\n---\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-a\n+b\n-- \n2.43.0\n").unwrap();
assert_eq!(ops.len(), 1);
// `*** End of File` inside a unified hunk.
let ops = parse("--- a/f\n+++ b/f\n@@\n-a\n+b\n*** End of File\n").unwrap();
assert!(update(&ops)[0].at_eof);
}
#[test]
fn unified_errors() {
assert!(parse("@@ -1 +1 @@\n-a\n+b\n").unwrap_err().contains("patch must include *** Begin Patch"));
assert!(parse("--- a/f\n+++ b/f\n@@ -x +y @@\n-a\n").unwrap_err().contains("cannot read"));
let e = parse("diff --git a/f b/f\nBinary files a/f and b/f differ\n").unwrap_err();
assert!(e.contains("Binary git patch is not supported"), "{e}");
assert!(parse("diff --git a/f b/f\nindex 1..2\n").unwrap_err().contains("no changes"));
assert!(parse("--- /dev/null\n+++ b/n\n@@ -1 +1 @@\n x\n").unwrap_err().contains("new file"));
let e = parse("--- a/f\n+++ b/f\n@@ -1 +1 @@\nzzz\n").unwrap_err();
assert!(e.contains("Hunk lines must start with space, +, or - (line 4: `zzz`)"), "{e}");
}
#[test]
fn applies_unified_hunks_near_their_line_numbers() {
let text = "x\nsame\nx\nx\nx\nsame\nx\n";
let ops = parse("--- a/f\n+++ b/f\n@@ -6,1 +6,1 @@\n-same\n+changed\n").unwrap();
assert_eq!(apply(text, update(&ops)).unwrap().text, "x\nsame\nx\nx\nx\nchanged\nx\n");
// Wrong line numbers still apply where the context is.
let ops = parse("--- a/f\n+++ b/f\n@@ -40,2 +40,2 @@\n x\n-same\n+changed\n").unwrap();
assert_eq!(apply(text, update(&ops)).unwrap().text, "x\nsame\nx\nx\nx\nchanged\nx\n");
// Insertion after line 2 (`-2,0`).
let ops = parse("--- a/f\n+++ b/f\n@@ -2,0 +3,1 @@\n+new\n").unwrap();
assert_eq!(apply(text, update(&ops)).unwrap().text, "x\nsame\nnew\nx\nx\nx\nsame\nx\n");
// Later line numbers shift by what earlier hunks added.
let ops = parse("--- a/f\n+++ b/f\n@@ -0,0 +1,2 @@\n+n1\n+n2\n@@ -6,1 +8,1 @@\n-same\n+changed\n").unwrap();
assert_eq!(apply(text, update(&ops)).unwrap().text, "n1\nn2\nx\nsame\nx\nx\nx\nchanged\nx\n");
}
#[test]
fn applies_the_git_example() {
let text = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\nfn e() {}\nfn f() {}\nfn g() {}\nfn h() {}\nfn i() {}\nfn j() {}\nfn l() {}\n";
let ops = parse(GIT).unwrap();
let out = apply(text, update(&ops)).unwrap().text;
assert_eq!(
out,
"fn a() {}\nfn b() { todo!() }\nfn c() {}\nfn d() {}\nfn e() {}\nfn f() {}\nfn g() {}\nfn h() {}\nfn i() {}\nfn j() {}\nfn k() {}\nfn l() {}\n"
);
}
#[test]
fn no_newline_markers() {
let add = parse("--- /dev/null\n+++ b/n\n@@ -0,0 +1 @@\n+x\n\\ No newline at end of file\n").unwrap();
assert_eq!(add[0], FileOp::Add { path: "n".into(), text: "x".into() });
// Adding the final newline.
let ops = parse("--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n-b\n\\ No newline at end of file\n+b\n").unwrap();
assert_eq!(apply("a\nb", update(&ops)).unwrap().text, "a\nb\n");
// Removing it.
let ops = parse("--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n-b\n+b\n\\ No newline at end of file\n").unwrap();
assert_eq!(apply("a\nb\n", update(&ops)).unwrap().text, "a\nb");
}
#[test]
fn splits_git_paths() {
assert_eq!(split_git_paths("a/x y b/x y"), (Some("x y".into()), Some("x y".into())));
assert_eq!(split_git_paths("a/x b/y"), (Some("x".into()), Some("y".into())));
assert_eq!(parse_range_header("@@ -3 +4,0 @@"), Some((3, 1, 0)));
assert_eq!(parse_range_header("@@ -1,2 +1,3 @@ fn x"), Some((1, 2, 3)));
assert_eq!(parse_range_header("@@@ -48,6 +48,7 @@@"), Some((48, 6, 7)));
assert_eq!(parse_range_header("@@ fn x"), None);
assert_eq!(unquote("\"a\\\"b\\\\c\""), "a\"b\\c");
}
#[test]
fn mode_changes_and_copies_do_not_fail_the_patch() {
// A mode-only section is an update without hunks; the hunks of the others still apply.
let diff = patch(&[
"diff --git a/run.sh b/run.sh",
"old mode 100644",
"new mode 100755",
"diff --git a/a.txt b/a.txt",
"--- a/a.txt",
"+++ b/a.txt",
"@@ -1 +1 @@",
"-x",
"+y",
]);
let ops = parse(&diff).unwrap();
assert_eq!(ops.len(), 2);
assert_eq!(ops[0], FileOp::Update { path: "run.sh".into(), move_to: None, hunks: Vec::new(), or: Vec::new() });
assert_eq!(update(&ops[1..]).len(), 1);
// A mode change together with a rename is a move.
let ops = parse("diff --git a/x b/y\nold mode 100644\nnew mode 100755\nrename from x\nrename to y\n").unwrap();
assert!(matches!(&ops[0], FileOp::Update { move_to: Some(m), .. } if m == "y"));
// A copy is skipped with a note.
let diff = patch(&[
"diff --git a/src/a.rs b/src/b.rs",
"similarity index 90%",
"copy from src/a.rs",
"copy to src/b.rs",
"--- a/src/a.rs",
"+++ b/src/b.rs",
"@@ -1 +1 @@",
"-a",
"+b",
"diff --git a/c.txt b/c.txt",
"--- a/c.txt",
"+++ b/c.txt",
"@@ -1 +1 @@",
"-1",
"+2",
]);
let ops = parse(&diff).unwrap();
assert_eq!(ops.len(), 2);
match &ops[0] {
FileOp::Skip { path, note } => {
assert_eq!(path, "src/b.rs");
assert!(note.contains("copy of src/a.rs to src/b.rs") && note.contains("not supported"), "{note}");
}
other => panic!("{other:?}"),
}
assert_eq!(ops[1].path(), "c.txt");
}
+950
View File
@@ -0,0 +1,950 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/RefactoringToolset.kt, renameTarget.kt), Apache-2.0.
//! `rename_refactoring` with the language server's rename. The target is given
//! as in JetBrains: a `contextSnippet`, else `line` + `column`, else the one
//! declaration of `symbolName` in the file; `targetIndex` picks from the
//! candidates an earlier call listed. Failures are results (`ok: false` with an
//! `error.kind`), not tool errors, so the agent can correct the call.
//!
//! Symbols are told apart by their definition (go to definition): the language
//! server has no PSI elements to compare. It renames code references only;
//! `searchInComments` / `searchInText` add whole-word occurrences found with
//! Find in Files.
use super::normalize_newlines;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::ops::search::SearchEvent;
use ide_core::{DocId, FileEdit, IdeError, IdeResult, Pos, Range, TextEdit, WorkspaceEdit};
use ide_lsp::types::GotoKind;
use ide_search::{SearchContext, SearchQuery, SearchScope};
use parking_lot::Mutex;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Occurrences of the name whose definition is looked up when no position is given.
const MAX_LOOKUPS: usize = 50;
/// Files `searchInText` treats as plain text.
const TEXT_EXTENSIONS: &[&str] = &["txt", "text", "md", "markdown", "rst", "adoc", "asciidoc", "properties"];
pub(super) fn rename_refactoring() -> Tool {
Tool::new(
"rename_refactoring",
"Rename a symbol and every reference to it. Set preview=true to analyze the rename and write nothing.
The language server resolves the symbol, so it also updates a reference that a text search cannot
find. Always prefer this tool over a text replacement.
Point at the symbol with ONE of these, in order of preference: contextSnippet, then line and column.
With neither of them, the file must declare one symbol with this name. symbolName is always required.
A position that resolves to another name refuses the rename.
targetIndex is not a third way to point at the symbol. It picks from a candidates list an earlier call
returned, so repeat that call with every argument it had, and add targetIndex.
Read `applied`. It is the only success signal. Nothing is written in part, except after
error.kind=write_failed, and its hint says what was written.
ok=false with candidates asks you to pick one with targetIndex. ok=false with conflicts asks for another
newName. ok=false with error tells you in `hint` what to correct.
applyAutomaticRenamers has no effect here: the language server renames the symbol and its references. A library or \
compiled symbol cannot be renamed.
By default, the rename changes no comment, no string literal and no plain-text occurrence of the name.
Set searchInComments or searchInText to change them too.",
|call| async move { run(&call).await },
)
.write()
.param(Param::required("pathInProject", ParamType::String, "Path relative to the project root"))
.param(Param::required("symbolName", ParamType::String, "Exact, case-sensitive name of the symbol to rename."))
.param(Param::required(
"newName",
ParamType::String,
"New name for the symbol. It must be a valid identifier for the symbol's language.",
))
.param(Param::optional(
"contextSnippet",
ParamType::String,
"Optional. Exact fragment of the file that shows the symbol. It must match the file once, and contain \
symbolName.",
))
.param(Param::optional(
"line",
ParamType::Integer,
"Optional. 1-based line of the symbol. Copy it from search_symbol output. Needs column too.",
))
.param(Param::optional(
"column",
ParamType::Integer,
"Optional. 1-based column of the symbol. Copy it from search_symbol output. Needs line too.",
))
.param(Param::optional(
"targetIndex",
ParamType::Integer,
"Optional. 1-based pick from the candidates list an earlier call returned. Repeat every other argument of \
that call.",
))
.param(Param::optional(
"preview",
ParamType::Boolean,
"Optional. Analyze only: report affects and conflicts, and write nothing. The default is false.",
))
.param(Param::optional(
"applyAutomaticRenamers",
ParamType::Boolean,
"Optional. Also rename what the IDE renames along with the symbol. The default is false.",
))
.param(Param::optional(
"searchInComments",
ParamType::Boolean,
"Optional. Also rename the name in comments and string literals. The default is false.",
))
.param(Param::optional(
"searchInText",
ParamType::Boolean,
"Optional. Also rename the name in plain-text occurrences, such as non-code files. The default is false.",
))
}
// --- the result ------------------------------------------------------------------
#[derive(Serialize, Default)]
#[serde(rename_all = "camelCase")]
struct RenameResult {
ok: bool,
applied: bool,
#[serde(skip_serializing_if = "Option::is_none")]
resolved_symbol: Option<SymbolInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
affects: Option<Affects>,
#[serde(skip_serializing_if = "Option::is_none")]
renamed_file: Option<RenamedFile>,
#[serde(skip_serializing_if = "Option::is_none")]
candidates: Option<Vec<Candidate>>,
#[serde(skip_serializing_if = "Option::is_none")]
conflicts: Option<Vec<Conflict>>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<RenameError>,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
impl RenameResult {
fn failed(kind: &'static str, hint: impl Into<String>) -> Self {
Self { error: Some(RenameError { kind, hint: hint.into() }), ..Self::default() }
}
fn with_symbol(mut self, symbol: Option<SymbolInfo>) -> Self {
self.resolved_symbol = symbol;
self
}
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct SymbolInfo {
name: String,
declaration_text: String,
declaration_file: String,
declaration_line: u32,
language: String,
}
/// The blast radius: files with an edit, and the edits.
#[derive(Serialize)]
struct Affects {
files: usize,
usages: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RenamedFile {
previous_path: String,
path: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Candidate {
target_index: usize,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<String>,
symbol: SymbolInfo,
}
#[derive(Serialize)]
struct Conflict {
description: String,
}
#[derive(Serialize)]
struct RenameError {
kind: &'static str,
hint: String,
}
// --- the call --------------------------------------------------------------------
struct Request {
name: String,
new_name: String,
snippet: Option<String>,
position: Option<(i64, i64)>,
target_index: Option<i64>,
preview: bool,
automatic_renamers: bool,
in_comments: bool,
in_text: bool,
}
async fn run(call: &Call) -> IdeResult<Output> {
let raw = call.str("pathInProject")?;
let name = call.str("symbolName")?;
let new_name = call.str("newName")?;
for (value, arg) in [(&raw, "pathInProject"), (&name, "symbolName"), (&new_name, "newName")] {
if value.trim().is_empty() {
return Err(IdeError::invalid(format!("{arg} is empty")));
}
}
let (line, column) = (call.opt_i64("line")?, call.opt_i64("column")?);
if line.is_some() != column.is_some() {
return Err(IdeError::invalid("line and column must be given together"));
}
let request = Request {
name,
new_name,
snippet: call.opt_str("contextSnippet")?,
position: line.zip(column),
target_index: call.opt_i64("targetIndex")?,
preview: call.flag("preview", false)?,
automatic_renamers: call.flag("applyAutomaticRenamers", false)?,
in_comments: call.flag("searchInComments", false)?,
in_text: call.flag("searchInText", false)?,
};
let path = call.resolve_inside(&raw)?;
if !path.is_file() {
return Err(IdeError::not_found(format!("File not found: {raw}")));
}
let host = call.host();
let project = call.project().clone();
let (p, target) = (project.clone(), path.clone());
let (doc, transient) = host
.blocking(move || {
// The rename reads and changes files across the project: no buffer may be older than its file.
let open: Vec<PathBuf> = p
.documents
.open_documents()
.into_iter()
.filter_map(|d| p.documents.snapshot(d).ok().map(|i| i.path.to_path_buf()))
.chain(std::iter::once(target.clone()))
.collect();
super::refresh_from_disk(&p, &open);
super::open_document(&p, &target)
})
.await?;
// Closed on every path out, a panic or a dropped call included.
let _close = CloseDocument(project.clone(), transient.then_some(doc));
Output::json(rename(call, doc, &path, &request).await?)
}
/// Closes a transient document when dropped.
struct CloseDocument(Arc<crate::Project>, Option<DocId>);
impl Drop for CloseDocument {
fn drop(&mut self) {
if let Some(doc) = self.1 {
self.0.documents.close(doc);
}
}
}
async fn rename(call: &Call, doc: DocId, path: &Path, req: &Request) -> IdeResult<RenameResult> {
let snap = call.project().documents.text_snapshot(doc)?;
let file = File { call, doc, path, text: &snap.text, language: snap.language.0.clone() };
let pos = match resolve_target(&file, req).await? {
Resolution::Resolved(pos) => pos,
Resolution::Unresolved(kind, hint) => return Ok(RenameResult::failed(kind, hint)),
Resolution::Ambiguous(positions) => {
let mut candidates = Vec::new();
for (i, pos) in positions.iter().enumerate() {
let (symbol, kind) = file.symbol_at(&req.name, *pos).await;
candidates.push(Candidate { target_index: i + 1, kind, symbol });
}
let hint = format!(
"'{}' matches {} symbols here. Nothing changed. Call again with every argument of this call plus \
targetIndex, or narrow the call with a contextSnippet that shows the one you mean.",
req.name,
positions.len()
);
return Ok(RenameResult { candidates: Some(candidates), ..RenameResult::failed("ambiguous_symbol", hint) });
}
};
let symbol = Some(file.symbol_at(&req.name, pos).await.0);
if req.name == req.new_name {
let hint = format!("The symbol already has the name '{}'.", req.new_name);
return Ok(RenameResult::failed("new_name_matches_current", hint).with_symbol(symbol));
}
if !plausible_identifier(&req.new_name) {
let hint = format!("'{}' is not a valid identifier for {}.", req.new_name, file.language);
return Ok(RenameResult::failed("new_name_invalid", hint).with_symbol(symbol));
}
let host = call.host();
let window = call.window();
match crate::ops::lsp::lsp_prepare_rename(&host, window, doc, pos).await {
Ok(Some(_)) => {}
Ok(None) => {
let hint = "This symbol cannot be renamed. A library or compiled symbol has to change upstream.";
return Ok(RenameResult::failed("not_renamable", hint).with_symbol(symbol));
}
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
Err(e) => return Ok(RenameResult::failed("language_not_supported", no_server_hint(&e)).with_symbol(symbol)),
}
let request = crate::mcp::tool::request_id();
let renamed = tokio::select! {
r = crate::ops::lsp::lsp_rename(&host, window, request, doc, pos, req.new_name.clone()) => r,
_ = call.cancel.cancelled() => {
host.cancel_request(request);
return Err(IdeError::cancelled());
}
};
let renamed = match renamed {
Ok(r) => r,
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
// Servers explain a clash with an existing name in the error (gopls: "would conflict with").
Err(e) if e.message.to_lowercase().contains("conflict") => {
let hint = "A conflict refused the rename and nothing changed. Choose another newName.";
let conflicts = Some(vec![Conflict { description: e.message }]);
return Ok(RenameResult { conflicts, ..RenameResult::failed("conflicts_found", hint) }.with_symbol(symbol));
}
Err(e) => return Ok(RenameResult::failed("new_name_refused", e.message).with_symbol(symbol)),
};
let mut edit = renamed.edit;
if edit.files.is_empty() {
return Ok(RenameResult::failed("unknown", "The language server found nothing to rename.").with_symbol(symbol));
}
if req.in_comments || req.in_text {
add_text_occurrences(call, &mut edit, req).await?;
}
for f in &edit.files {
let paths: Vec<&Path> = match f {
FileEdit::Edit { path, .. } | FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => vec![path],
FileEdit::Rename { from, to } => vec![from, to],
};
for p in paths {
if let Err(e) = super::super::tool::ensure_inside(call.root(), p) {
let hint = format!(
"{}. Nothing was renamed. A rename that must change a file outside the project cannot run here.",
e.message
);
return Ok(RenameResult::failed("read_only_usages", hint).with_symbol(symbol));
}
}
}
let mut files: Vec<&Path> = Vec::new();
let mut usages = 0;
for f in &edit.files {
if let FileEdit::Edit { path, edits, .. } = f {
usages += edits.len();
if !files.contains(&&**path) {
files.push(path);
}
}
}
let affects = Some(Affects { files: files.len(), usages });
let mut notes = Vec::new();
if renamed.incomplete.is_some() {
notes.push("The language server reported that the references may be incomplete.");
}
if req.automatic_renamers {
notes.push(
"applyAutomaticRenamers had no effect: the language server renames the symbol and its references only.",
);
}
let note = (!notes.is_empty()).then(|| notes.join(" "));
if req.preview {
return Ok(RenameResult { ok: true, affects, resolved_symbol: symbol, note, ..RenameResult::default() });
}
// The IDE's configuration among the files: the user agrees first.
let mut checked: Vec<&Path> = Vec::new();
for f in &edit.files {
let paths: Vec<&Path> = match f {
FileEdit::Edit { path, .. } | FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => vec![path],
FileEdit::Rename { from, to } => vec![from, to],
};
for p in paths {
if !checked.contains(&p) {
checked.push(p);
call.check_writable(p).await?;
}
}
}
let renamed_file = edit.files.iter().find_map(|f| match f {
FileEdit::Rename { from, to } if super::same_file(from, path) => {
Some(RenamedFile { previous_path: call.display(from), path: call.display(to) })
}
_ => None,
});
let (h, w) = (host.clone(), window.to_owned());
let written = crate::ops::lsp::apply_workspace_edit(&h, &w, edit).await;
if let Err(e) = written {
let hint = format!("The rename stopped while it wrote. {} Read the files again before you retry.", e.message);
return Ok(RenameResult::failed("write_failed", hint).with_symbol(symbol));
}
Ok(RenameResult {
ok: true,
applied: true,
resolved_symbol: symbol,
affects,
renamed_file,
note,
..RenameResult::default()
})
}
fn no_server_hint(e: &IdeError) -> String {
format!(
"No language server can rename in this file ({}), so nothing was renamed. Without one, change the \
occurrences with apply_patch.",
e.message
)
}
/// Refuses only what no language accepts in a name: whitespace, separators, a leading digit.
fn plausible_identifier(name: &str) -> bool {
!name.starts_with(|c: char| c.is_ascii_digit())
&& !name.chars().any(|c| c.is_whitespace() || c.is_control() || ".,;:()[]{}<>\"'`/\\+*=!?&|^%#~".contains(c))
}
// --- the target ------------------------------------------------------------------
enum Resolution {
Resolved(Pos),
/// Several symbols, in document order; `targetIndex` is 1-based.
Ambiguous(Vec<Pos>),
Unresolved(&'static str, String),
}
/// The file the rename starts in.
struct File<'a> {
call: &'a Call,
doc: DocId,
path: &'a Path,
text: &'a str,
language: String,
}
impl File<'_> {
fn line(&self, line: u32) -> &str {
self.text.split('\n').nth(line as usize).unwrap_or("")
}
/// Where the symbol at `pos` is defined; `None` without an answer.
async fn definition(&self, pos: Pos) -> Option<(PathBuf, Pos)> {
let host = self.call.host();
let found = crate::ops::lsp::lsp_goto(&host, self.call.window(), self.doc, pos, GotoKind::Definition).await;
let loc = found.ok()?.into_iter().find(|l| l.uri.is_none())?;
Some((PathBuf::from(&*loc.path), loc.range.start))
}
/// Whether the language server can rename at `pos`; `Err` without one.
async fn renameable(&self, pos: Pos) -> Result<bool, Resolution> {
let host = self.call.host();
match crate::ops::lsp::lsp_prepare_rename(&host, self.call.window(), self.doc, pos).await {
Ok(found) => Ok(found.is_some()),
Err(e) => Err(Resolution::Unresolved("language_not_supported", no_server_hint(&e))),
}
}
/// The symbol (from its definition when known) and its outline kind.
async fn symbol_at(&self, name: &str, pos: Pos) -> (SymbolInfo, Option<String>) {
let (file, at) = match self.definition(pos).await {
Some((file, at)) if super::same_file(&file, self.path) => (self.path.to_path_buf(), at),
Some((file, at)) => (file, at),
None => (self.path.to_path_buf(), pos),
};
let declaration_text = if file == self.path {
self.line(at.line).trim().to_owned()
} else {
let project = self.call.project();
project
.text_of(&file)
.ok()
.and_then(|t| t.split('\n').nth(at.line as usize).map(|l| l.trim().to_owned()))
.unwrap_or_default()
};
let kind = if file == self.path { self.kind_at(at).await } else { None };
let symbol = SymbolInfo {
name: name.to_owned(),
declaration_text,
declaration_file: self.call.display(&file),
declaration_line: at.line + 1,
language: self.language.clone(),
};
(symbol, kind)
}
/// The outline kind (`field`, `method`) of the declaration whose name is at `pos`.
async fn kind_at(&self, pos: Pos) -> Option<String> {
let host = self.call.host();
let outline = crate::ops::doc::doc_outline(&host, self.call.window(), self.doc).await.ok()?;
let mut stack: Vec<&ide_core::OutlineItem> = outline.iter().collect();
while let Some(item) = stack.pop() {
let r = item.selection_range;
if r.start <= pos && pos <= r.end {
let kind = format!("{:?}", item.kind);
return Some(kind[..1].to_lowercase() + &kind[1..]);
}
stack.extend(item.children.iter());
}
None
}
/// Positions of `name` as an identifier (outside comments and strings when a grammar knows them).
async fn identifiers(&self, name: &str) -> Vec<Pos> {
let host = self.call.host();
match crate::ops::syntax::syntax_identifiers(&host, self.call.window(), self.doc, name.to_owned(), None).await {
Ok(found) if !found.is_empty() => found.into_iter().map(|o| o.range.start).collect(),
_ => word_positions(self.text, name),
}
}
}
/// The symbol the caller pointed at, or why the tool cannot tell which one it is.
/// `targetIndex` picks from the list of the branch the other arguments select.
async fn resolve_target(file: &File<'_>, req: &Request) -> IdeResult<Resolution> {
let resolution = if let Some(snippet) = &req.snippet {
by_snippet(file, snippet, &req.name).await
} else if let Some((line, column)) = req.position {
by_position(file, line, column, &req.name).await
} else {
by_declaration(file, &req.name).await?
};
let Some(index) = req.target_index else { return Ok(resolution) };
Ok(match resolution {
Resolution::Ambiguous(candidates) => pick(&candidates, index),
// One symbol now: only a pick of the first agrees with it; the code changed since the list.
Resolution::Resolved(pos) if index == 1 => Resolution::Resolved(pos),
Resolution::Resolved(pos) => pick(&[pos], index),
unresolved => unresolved,
})
}
fn pick(candidates: &[Pos], index: i64) -> Resolution {
let n = candidates.len();
if index < 1 || index as usize > n {
return Resolution::Unresolved(
"target_index_out_of_range",
format!(
"targetIndex {index} is outside 1..{n}. This call reports {n} candidate(s). Send the same \
pathInProject, symbolName and contextSnippet as the call that listed them."
),
);
}
Resolution::Resolved(candidates[index as usize - 1])
}
async fn by_snippet(file: &File<'_>, snippet: &str, name: &str) -> Resolution {
if snippet.trim().is_empty() {
return Resolution::Unresolved("snippet_not_found", "contextSnippet is blank.".into());
}
let needle = normalize_newlines(snippet);
if !needle.contains(name) {
return Resolution::Unresolved(
"name_mismatch",
format!("contextSnippet does not contain '{name}'. Pass a snippet that shows the symbol."),
);
}
let start = match find_snippet(file.text, &needle) {
SnippetMatch::NotFound => {
return Resolution::Unresolved(
"snippet_not_found",
"contextSnippet is not in this file. Read the file again and copy the snippet from its current text."
.into(),
);
}
SnippetMatch::NotUnique => {
return Resolution::Unresolved(
"snippet_not_unique",
"contextSnippet matches this file more than once. Extend it until it is unique.".into(),
);
}
SnippetMatch::At(start) => start,
};
let mut symbols: Vec<(Option<(PathBuf, Pos)>, Pos)> = Vec::new();
// Why an occurrence could not be asked about; reported only when no other one resolves.
let mut refused = None;
for pos in snippet_occurrences(file.text, start, needle.len(), name) {
match file.renameable(pos).await {
Ok(true) => {}
Ok(false) => continue,
Err(no_server) => {
refused.get_or_insert(no_server);
continue;
}
}
let definition = file.definition(pos).await;
if definition.is_none() || !symbols.iter().any(|(d, _)| *d == definition) {
symbols.push((definition, pos));
}
}
if let (true, Some(refused)) = (symbols.is_empty(), refused) {
return refused;
}
match symbols.len() {
0 => Resolution::Unresolved(
"symbol_not_found",
format!("'{name}' inside contextSnippet does not resolve to a symbol that can be renamed."),
),
1 => Resolution::Resolved(symbols[0].1),
_ => Resolution::Ambiguous(symbols.into_iter().map(|(_, p)| p).collect()),
}
}
async fn by_position(file: &File<'_>, line: i64, column: i64, name: &str) -> Resolution {
let line_count = file.text.split('\n').count() as i64;
if line < 1 || column < 1 || line > line_count {
return Resolution::Unresolved(
"position_out_of_bounds",
format!("Position {line}:{column} is outside this file. Both numbers are 1-based."),
);
}
let text = file.line(line as u32 - 1);
let units: Vec<u16> = text.encode_utf16().collect();
// The column has to stay on the named line: past its end it would land further down.
if column as usize - 1 > units.len() {
return Resolution::Unresolved(
"position_out_of_bounds",
format!("Column {column} is past the end of line {line}. Both numbers are 1-based."),
);
}
let pos = Pos::new(line as u32 - 1, column as u32 - 1);
let word = word_at(&units, pos.col as usize);
let in_code = file
.identifiers(name)
.await
.iter()
.any(|p| p.line == pos.line && p.col <= pos.col && pos.col <= p.col + units_len(name));
match word {
Some((start, w)) if w == name && in_code => {
let at = Pos::new(pos.line, start as u32);
match file.renameable(at).await {
Ok(true) => Resolution::Resolved(at),
Ok(false) => Resolution::Unresolved(
"symbol_not_found",
format!("No symbol that can be renamed sits at {line}:{column}."),
),
Err(no_server) => no_server,
}
}
Some((_, other)) if other != name => Resolution::Unresolved(
"name_mismatch",
format!("Position {line}:{column} resolves to '{other}', not to '{name}'. Nothing was renamed."),
),
_ => Resolution::Unresolved(
"symbol_not_found",
format!("No symbol that can be renamed sits at {line}:{column}."),
),
}
}
/// The declarations of `name` in this file: the distinct definitions of its occurrences
/// that lie in this file (declared-looking occurrences without go to definition).
async fn by_declaration(file: &File<'_>, name: &str) -> IdeResult<Resolution> {
let occurrences = file.identifiers(name).await;
let mut declarations: Vec<Pos> = Vec::new();
let mut answered = false;
for pos in occurrences.iter().take(MAX_LOOKUPS) {
if file.call.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if let Some((path, at)) = file.definition(*pos).await {
answered = true;
if super::same_file(&path, file.path) && !declarations.contains(&at) {
declarations.push(at);
}
}
}
if !answered {
declarations = occurrences.into_iter().filter(|p| is_declaration(file.text, *p)).collect();
}
declarations.sort();
Ok(match declarations.len() {
0 => Resolution::Unresolved(
"symbol_not_found",
format!("No symbol named '{name}' is declared in this file. Pass the file that declares it."),
),
1 => Resolution::Resolved(declarations[0]),
_ => Resolution::Ambiguous(declarations),
})
}
fn units_len(s: &str) -> u32 {
s.encode_utf16().count() as u32
}
fn is_word_unit(u: u16) -> bool {
char::from_u32(u as u32).is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$') || u >= 0x80
}
/// The identifier-like word touching UTF-16 column `col`: (start column, word).
fn word_at(units: &[u16], col: usize) -> Option<(usize, String)> {
let touches = |i: usize| units.get(i).is_some_and(|&u| is_word_unit(u));
let at = if touches(col) {
col
} else if col > 0 && touches(col - 1) {
col - 1
} else {
return None;
};
let start = (0..=at).rev().take_while(|&i| touches(i)).last().unwrap_or(at);
let end = (at..units.len()).take_while(|&i| touches(i)).last().map_or(at, |e| e + 1);
Some((start, String::from_utf16_lossy(&units[start..end])))
}
enum SnippetMatch {
NotFound,
NotUnique,
/// The byte offset of the only occurrence.
At(usize),
}
/// Where `needle` occurs in `text`, overlapping occurrences included.
fn find_snippet(text: &str, needle: &str) -> SnippetMatch {
let Some(start) = text.find(needle) else { return SnippetMatch::NotFound };
// One character further, not one byte: the needle may start with a multibyte character.
let step = needle.chars().next().map_or(1, char::len_utf8);
if text[start + step..].contains(needle) { SnippetMatch::NotUnique } else { SnippetMatch::At(start) }
}
/// Whole-word occurrences of `name` inside the `len` bytes of `text` from byte `start`
/// (a snippet's place in the file): the file's text around an occurrence decides whether
/// it is a whole word, so a snippet cut in the middle of a word does not make one.
fn snippet_occurrences(text: &str, start: usize, len: usize, name: &str) -> Vec<Pos> {
let is_word = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
let mut out = Vec::new();
for (at, _) in text[start..start + len].match_indices(name) {
let at = start + at;
let before = text[..at].chars().next_back();
let after = text[at + name.len()..].chars().next();
if before.is_some_and(is_word) || after.is_some_and(is_word) {
continue;
}
let line_start = text[..at].rfind('\n').map_or(0, |i| i + 1);
out.push(Pos::new(text[..at].matches('\n').count() as u32, units_len(&text[line_start..at])));
}
out
}
/// Whole-word occurrences of `name` (0-based line, UTF-16 column).
fn word_positions(text: &str, name: &str) -> Vec<Pos> {
let is_word = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
let mut out = Vec::new();
for (line_no, line) in text.split('\n').enumerate() {
for (at, _) in line.match_indices(name) {
let before = line[..at].chars().next_back();
let after = line[at + name.len()..].chars().next();
if before.is_some_and(is_word) || after.is_some_and(is_word) {
continue;
}
out.push(Pos::new(line_no as u32, units_len(&line[..at])));
}
}
out
}
/// Words that introduce a declaration of the name after them.
const DECLARATION_KEYWORDS: &[&str] = &[
"fn",
"def",
"class",
"struct",
"enum",
"trait",
"interface",
"type",
"func",
"function",
"fun",
"let",
"const",
"var",
"val",
"mod",
"module",
"object",
"record",
"typealias",
"protocol",
"union",
"namespace",
"package",
"static",
"macro_rules!",
];
/// Whether the occurrence at `p` follows a declaration keyword (`fn name`, `let mut name`).
fn is_declaration(text: &str, p: Pos) -> bool {
let Some(line) = text.split('\n').nth(p.line as usize) else { return false };
let mut units = 0u32;
let byte = line
.char_indices()
.find(|(_, c)| {
let here = units >= p.col;
units += c.len_utf16() as u32;
here
})
.map_or(line.len(), |(i, _)| i);
let mut words = line[..byte].split_whitespace().rev();
match words.next() {
Some("mut") => words.next() == Some("let"),
Some(w) => DECLARATION_KEYWORDS.contains(&w),
None => false,
}
}
// --- comments and text -------------------------------------------------------------
/// Adds whole-word occurrences of the old name to `edit`: in comments and string
/// literals of the files it changes, and in plain-text files of the project.
async fn add_text_occurrences(call: &Call, edit: &mut WorkspaceEdit, req: &Request) -> IdeResult<()> {
let edited: Vec<PathBuf> = edit
.files
.iter()
.filter_map(|f| match f {
FileEdit::Edit { path, .. } => Some(path.to_path_buf()),
_ => None,
})
.collect();
let mut found = Vec::new();
if req.in_comments {
let scope = || SearchScope::Files { paths: edited.iter().cloned().map(Into::into).collect() };
found.extend(occurrences(call, &req.name, scope(), SearchContext::InComments).await?);
found.extend(occurrences(call, &req.name, scope(), SearchContext::InStringLiterals).await?);
}
if req.in_text {
let text_file = |p: &Path| {
p.extension().and_then(|e| e.to_str()).is_some_and(|e| TEXT_EXTENSIONS.contains(&e.to_lowercase().as_str()))
};
found.extend(
occurrences(call, &req.name, SearchScope::Project, SearchContext::Any)
.await?
.into_iter()
.filter(|(p, _)| text_file(p) && !edited.iter().any(|e| super::same_file(e, p))),
);
}
for (path, range) in found {
let text_edit = TextEdit { range, new_text: req.new_name.clone() };
let entry = edit.files.iter_mut().find_map(|f| match f {
FileEdit::Edit { path: p, edits, .. } if super::same_file(p, &path) => Some(edits),
_ => None,
});
match entry {
Some(edits) => {
let overlaps =
edits.iter().any(|e| e.range.start < range.end && range.start < e.range.end || e.range == range);
if !overlaps {
edits.push(text_edit);
}
}
None => edit.files.push(FileEdit::Edit { path: path.into(), edits: vec![text_edit], version: None }),
}
}
Ok(())
}
/// Whole-word, case-sensitive Find in Files matches of `name`.
async fn occurrences(
call: &Call,
name: &str,
scope: SearchScope,
context: SearchContext,
) -> IdeResult<Vec<(PathBuf, Range)>> {
let query = SearchQuery {
pattern: name.to_owned(),
is_regex: false,
case_sensitive: true,
whole_word: true,
include: Vec::new(),
exclude: Vec::new(),
scope,
max_matches: 0,
context,
};
let host = call.host();
let request = crate::mcp::tool::request_id();
let files = Arc::new(Mutex::new(Vec::new()));
let emit: crate::ops::Emit<SearchEvent> = {
let files = files.clone();
Arc::new(move |event| {
if let SearchEvent::File { file } = event {
files.lock().push(file);
}
})
};
let search = crate::ops::search::start(&host, call.window(), request, query, emit);
tokio::pin!(search);
tokio::select! {
r = &mut search => r?,
_ = call.cancel.cancelled() => {
host.cancel_request(request);
let _ = search.await;
return Err(IdeError::cancelled());
}
}
let width = units_len(name);
let mut out = Vec::new();
for file in std::mem::take(&mut *files.lock()) {
for m in &file.matches {
for &(from, _) in &m.ranges {
let start = Pos::new(m.line, m.text_offset + from);
out.push((file.path.to_path_buf(), Range::new(start, Pos::new(m.line, start.col + width))));
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_whole_words_in_utf16_columns() {
let text = "let foo = foo_bar + foo;\n// ä foo\nfoofoo";
assert_eq!(word_positions(text, "foo"), vec![Pos::new(0, 4), Pos::new(0, 20), Pos::new(1, 5)]);
let units: Vec<u16> = " int counter;".encode_utf16().collect();
assert_eq!(word_at(&units, 6), Some((6, "counter".into())));
assert_eq!(word_at(&units, 13), Some((6, "counter".into())));
assert_eq!(word_at(&units, 0), None);
}
#[test]
fn finds_snippets_starting_with_multibyte_characters() {
let text = "let äb = 1;\nlet äb2 = äb;\n";
assert!(matches!(find_snippet(text, "äb"), SnippetMatch::NotUnique));
assert!(matches!(find_snippet(text, "äb = 1"), SnippetMatch::At(4)));
assert!(matches!(find_snippet(text, "ö"), SnippetMatch::NotFound));
// Overlapping occurrences are two.
assert!(matches!(find_snippet("aaa", "aa"), SnippetMatch::NotUnique));
}
#[test]
fn snippet_occurrences_are_whole_words_of_the_file() {
let text = "let beta = 1;\nlet x = alphabeta + beta;\n";
// The snippet starts inside `alphabeta`: its `beta` is not a word of the file.
let needle = "beta + beta";
let SnippetMatch::At(start) = find_snippet(text, needle) else { panic!() };
assert_eq!(snippet_occurrences(text, start, needle.len(), "beta"), vec![Pos::new(1, 20)]);
let text = "ä beta";
assert_eq!(snippet_occurrences(text, 0, text.len(), "beta"), vec![Pos::new(0, 2)]);
}
#[test]
fn recognizes_declarations_and_identifiers() {
let text = "use x;\nprint(count)\nlet mut count = 1;\nfn count() {}\n";
assert!(!is_declaration(text, Pos::new(1, 6)));
assert!(is_declaration(text, Pos::new(2, 8)) && is_declaration(text, Pos::new(3, 3)));
assert!(plausible_identifier("hitCount") && plausible_identifier("kebab-case") && plausible_identifier("$x"));
assert!(!plausible_identifier("1abc") && !plausible_identifier("bad name") && !plausible_identifier("a.b"));
}
}
+98
View File
@@ -0,0 +1,98 @@
use super::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{Diagnostic, IdeResult, Severity};
use serde_json::{Value, json};
pub fn tools() -> Vec<Tool> {
vec![
Tool::new(
"get_all_open_file_paths",
"Returns active editor's and other open editors' file paths relative to the project root.
Use this tool to explore current open editors.",
open_editors,
),
Tool::new(
"get_current_file",
"Returns the focused editor's file and selection reported by the IDE.
Selection lines and UTF-16 columns are 1-based, end exclusive.
Selected text is limited to 65536 UTF-16 units.",
current_file,
),
Tool::new(
"get_file_problems",
"Returns the problems for a file as reported by the IDE's Problems view, including language servers
and inspections after profile filtering. This reflects what the Problems view shows, rather than a fresh analysis.
Lines and UTF-16 columns are 1-based.",
file_problems,
)
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::optional(
"errorsOnly",
ParamType::Boolean,
"Whether to include only errors or include both errors and warnings",
)),
Tool::new(
"get_project_problems",
"Returns the project's problems as reported by the IDE's Problems view, including language servers
and inspections after profile filtering. This reflects what the Problems view shows, rather than a fresh analysis.
Lines and UTF-16 columns are 1-based.",
project_problems,
),
]
}
async fn open_editors(call: Call) -> IdeResult<Output> {
let windows = call.host.mcp_windows(call.root());
let open = call.host.mcp_reports.editors(&windows);
let active = call.host.mcp_reports.focused(call.window());
Output::json(json!({
"activeFilePath": active.map(|e| call.display(&e.path.0)),
"openFiles": open.iter().map(|p| call.display(p)).collect::<Vec<_>>(),
}))
}
async fn current_file(call: Call) -> IdeResult<Output> {
let editor = call.host.mcp_reports.focused(call.window());
Output::json(match editor {
None => json!({}),
Some(e) => json!({
"filePath": call.display(&e.path.0), "selectedText": e.selected_text,
"selection": {
"startLine": e.selection.start.line + 1, "startColumn": e.selection.start.col + 1,
"endLine": e.selection.end.line + 1, "endColumn": e.selection.end.col + 1,
},
}),
})
}
fn problem(d: Diagnostic, text: &str) -> Value {
json!({
"severity": d.severity, "description": d.message,
"lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(),
"line": d.range.start.line + 1, "column": d.range.start.col + 1, "source": d.source, "code": d.code,
})
}
async fn file_problems(call: Call) -> IdeResult<Output> {
let path = call.resolve_readable(&call.str("filePath")?).await?;
let errors_only = call.flag("errorsOnly", true)?;
let files = call.host.mcp_reports.problems(&call.host.mcp_windows(call.root()));
let (project, file) = (call.project.clone(), path.clone());
let text = call.host.blocking(move || project.text_of(&file)).await.unwrap_or_default();
let errors: Vec<_> = files
.into_iter()
.filter(|f| f.path.0 == path)
.flat_map(|f| f.items)
.filter(|d| !errors_only || d.severity == Severity::Error)
.map(|d| problem(d, &text))
.collect();
Output::json(json!({"filePath": call.display(&path), "errors": errors}))
}
async fn project_problems(call: Call) -> IdeResult<Output> {
let files = call.host.mcp_reports.problems(&call.host.mcp_windows(call.root()));
let files: Vec<_> = files.into_iter().map(|f| json!({
"filePath":call.display(&f.path.0), "problems": f.items.into_iter().map(|d| problem(d, "")).collect::<Vec<_>>()
})).collect();
Output::json(json!({"files":files}))
}
+280
View File
@@ -0,0 +1,280 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/ReadToolset.kt,
// the list_directory_tree part of FileToolset.kt, src/com/intellij/mcpserver/util/fs.util.kt), Apache-2.0.
//! Reading files and listing directories.
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::mcp::util::{
self, RELATIVE_PATH_IN_PROJECT_DESCRIPTION, TIMED_OUT_DESCRIPTION, TIMEOUT_MILLISECONDS_DESCRIPTION,
};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::Serialize;
use serde_json::json;
use std::path::Path;
use std::time::{Duration, Instant};
const DEFAULT_READ_LIMIT: i64 = 2000;
const MAX_READ_LIMIT: i64 = 5000;
/// Longer lines are cut (minified files would flood the context), in UTF-16 units.
const MAX_LINE_LENGTH: usize = 2000;
const DEFAULT_TREE_DEPTH: i64 = 5;
pub fn tools() -> Vec<Tool> {
vec![read_file(), list_directory_tree()]
}
// --- read_file -----------------------------------------------------------------
fn read_file() -> Tool {
Tool::new(
"read_file",
"Reads a file in the project directory or from any project dependency or other project source root.
Can read text sources inside jar/zip archives; compiled class files are not decompiled.
Returns numbered lines (1-indexed) as text.
By default, reads up to 2000 lines starting from the beginning of the file.
The maximum accepted limit is 5000 lines.",
|call| async move {
let offset = positive(&call, "offset", 1)?;
let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?;
if limit > MAX_READ_LIMIT {
return Err(IdeError::invalid(format!("limit must be <= {MAX_READ_LIMIT}")));
}
let path = util::resolve_read_file(&call, &call.str("file_path")?).await?;
if call.project().documents.by_path(&path).is_none()
&& let probe = ide_vfs::probe(&path)?
&& probe.binary
{
return Err(IdeError::invalid(format!(
"{} is a binary file ({} bytes); read_file reads text",
call.display(&path),
probe.size
)));
}
let text = crate::ops::fs::read_text(&call.host(), call.window(), path).await?;
Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?))
},
)
.param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION))
.param(Param::optional("offset", ParamType::Integer, "1-based line number to start reading from"))
.param(Param::optional(
"limit",
ParamType::Integer,
"Maximum number of lines to return (default: 2000, max: 5000)",
))
}
/// An integer argument that must be > 0.
fn positive(call: &Call, name: &str, default: i64) -> IdeResult<i64> {
match call.opt_i64(name)?.unwrap_or(default) {
n if n <= 0 => Err(IdeError::invalid(format!("{name} must be > 0"))),
n => Ok(n),
}
}
/// Lines `start..` of `text` as `L<n>: <line>`; when more than `max_lines` remain, the first
/// and the last halves of the budget with a `…N lines truncated…` marker between them.
/// Lines are the editor's: a trailing line break starts an empty last line.
fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String> {
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let count = lines.len();
if start > count {
return Err(IdeError::invalid("offset exceeds file length"));
}
let range = |from: usize, to: usize| {
(from..=to).map(|n| format!("L{n}: {}", format_line(lines[n - 1]))).collect::<Vec<_>>().join("\n")
};
let available = count - start + 1;
if available <= max_lines {
return Ok(range(start, count));
}
let head = max_lines / 2;
let tail = max_lines - head;
let head_end = start + head - 1;
let tail_start = count - tail + 1;
let mut out = String::new();
if head > 0 {
out.push_str(&range(start, head_end));
out.push('\n');
}
out.push_str(&format!("\u{2026}{} lines truncated\u{2026}", tail_start - head_end - 1));
if tail > 0 {
out.push('\n');
out.push_str(&range(tail_start, count));
}
Ok(out)
}
/// `line` cut to [`MAX_LINE_LENGTH`] UTF-16 units, or as many characters when the cut would
/// split a surrogate pair (as JetBrains does).
fn format_line(line: &str) -> &str {
let mut units = 0;
for (i, c) in line.char_indices() {
if units >= MAX_LINE_LENGTH {
return &line[..i];
}
if units + c.len_utf16() > MAX_LINE_LENGTH {
// A pair across the boundary: keep MAX_LINE_LENGTH characters instead.
return match line.char_indices().nth(MAX_LINE_LENGTH) {
Some((cut, _)) => &line[..cut],
None => line,
};
}
units += c.len_utf16();
}
line
}
// --- list_directory_tree -------------------------------------------------------
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DirectoryTreeInfo {
traversed_directory: String,
tree: String,
errors: Vec<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
listing_timed_out: bool,
}
fn list_directory_tree() -> Tool {
Tool::new(
"list_directory_tree",
"Provides a tree representation of the specified directory in the pseudo graphic format like `tree` utility does.
Use this tool to explore the contents of a directory or the whole project.
You MUST prefer this tool over listing directories via command line utilities like `ls` or `dir`.",
|call| async move {
let directory = call.str("directoryPath")?;
let max_depth = call.opt_i64("maxDepth")?.unwrap_or(DEFAULT_TREE_DEPTH);
let timeout = call.opt_i64("timeout")?.unwrap_or(util::MEDIUM_TIMEOUT.as_millis() as i64);
let dir = call.resolve_readable(&directory).await?;
if !dir.exists() {
return Err(IdeError::not_found(format!("No such directory: {}", dir.display())));
}
if !dir.is_dir() {
return Err(IdeError::invalid(format!("Not a directory: {}", dir.display())));
}
let deadline = Instant::now() + Duration::from_millis(timeout.max(0) as u64);
let cancel = call.cancel.clone();
let mut tree =
TreeRender { out: String::new(), errors: Vec::new(), timed_out: false, deadline, cancel: cancel.clone() };
let tree = call
.host()
.blocking(move || {
tree.render(&dir, "", true, max_depth, true);
Ok(tree)
})
.await?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Output::json(DirectoryTreeInfo {
traversed_directory: directory,
tree: tree.out,
errors: tree.errors,
listing_timed_out: tree.timed_out,
})
},
)
.param(Param::required("directoryPath", ParamType::String, RELATIVE_PATH_IN_PROJECT_DESCRIPTION))
.param(Param::optional("maxDepth", ParamType::Integer, "Maximum recursion depth"))
.param(Param::optional("timeout", ParamType::Integer, TIMEOUT_MILLISECONDS_DESCRIPTION))
.output(json!({
"type": "object",
"properties": {
"traversedDirectory": { "type": "string" },
"tree": { "type": "string" },
"errors": { "type": "array", "items": { "type": "string" } },
"listingTimedOut": { "type": "boolean", "description": TIMED_OUT_DESCRIPTION },
},
"required": ["traversedDirectory", "tree", "errors"],
}))
}
/// The `tree`-like listing: the directory's absolute path, then every entry (hidden and
/// ignored ones too) by lower-cased name, directories marked with `/`.
struct TreeRender {
out: String,
errors: Vec<String>,
timed_out: bool,
deadline: Instant,
cancel: CancellationToken,
}
impl TreeRender {
fn render(&mut self, path: &Path, indent: &str, is_last: bool, max_depth: i64, is_root: bool) {
if max_depth <= 0 || self.timed_out || self.cancel.is_cancelled() {
return;
}
if Instant::now() >= self.deadline {
self.timed_out = true;
return;
}
let prefix = match (is_root, is_last) {
(true, _) => "",
(false, true) => "└── ",
(false, false) => "├── ",
};
let name = if is_root {
path.display().to_string()
} else {
path.file_name().unwrap_or_default().to_string_lossy().into_owned()
};
let is_dir = path.is_dir() && !path.is_symlink();
self.out.push_str(&format!("{indent}{prefix}{name}{}\n", if is_dir { "/" } else { "" }));
if !is_dir {
return;
}
let mut children: Vec<_> = match std::fs::read_dir(path) {
Ok(entries) => entries.filter_map(|e| e.ok().map(|e| e.path())).collect(),
Err(e) => {
self.errors.push(format!("Failed to read {}: {e}", path.display()));
return;
}
};
let key = |p: &std::path::PathBuf| p.file_name().unwrap_or_default().to_string_lossy().into_owned();
children.sort_by_cached_key(|p| (key(p).to_lowercase(), key(p)));
let child_indent = format!("{indent}{}", if is_last { " " } else { "│ " });
let n = children.len();
for (i, child) in children.iter().enumerate() {
self.render(child, &child_indent, i + 1 == n, max_depth - 1, false);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn slice(text: &str, start: usize, max: usize) -> String {
render_slice(text, start, max).unwrap()
}
#[test]
fn numbers_lines_like_the_editor() {
assert_eq!(slice("a\nb", 1, 10), "L1: a\nL2: b");
assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: ");
assert_eq!(slice("", 1, 10), "L1: ");
assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c");
assert!(render_slice("", 2, 10).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("a\nb", 3, 10).unwrap_err().message.contains("offset exceeds file length"));
}
#[test]
fn keeps_head_and_tail_of_long_slices() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
assert_eq!(slice(&text, 1, 4), "L1: line 1\nL2: line 2\n…7 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 3, 4), "L3: line 3\nL4: line 4\n…5 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 1, 1), "…10 lines truncated…\nL11: line 11");
}
#[test]
fn cuts_long_lines_at_utf16_units() {
let long = "x".repeat(2100);
assert_eq!(format_line(&long).len(), MAX_LINE_LENGTH);
// A pair across the boundary keeps 2000 characters.
let pair = format!("{}😀{}", "x".repeat(1999), "y".repeat(10));
assert_eq!(format_line(&pair).chars().count(), MAX_LINE_LENGTH);
assert!(format_line(&pair).ends_with('😀'));
let short = format!("{}😀", "x".repeat(1998));
assert_eq!(format_line(&short), short);
}
}
+142
View File
@@ -0,0 +1,142 @@
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
mod edit;
mod editors;
mod requests;
mod run;
mod vcs;
pub use requests::Requests;
mod files;
mod reports;
mod search;
mod symbols;
mod tool;
mod util;
use crate::Host;
use ide_core::{CancellationToken, IdeError};
use ide_mcp::{ToolCallError, ToolRegistry};
pub use reports::{FileProblems, FocusedEditor, Reports};
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct Registry(Arc<Host>);
impl Registry {
pub fn new(host: Arc<Host>) -> Self {
Self(host)
}
}
fn tools() -> Vec<tool::Tool> {
let mut tools = files::tools();
tools.extend(search::tools());
tools.extend(editors::tools());
tools.push(symbols::get_symbol_info());
tools.push(symbols::find_references());
tools.extend(edit::tools());
tools.extend(run::tools());
tools.extend(vcs::tools());
tools
}
// Checked both when listing and calling, so a later trust change takes effect immediately.
fn allowed(tool: &ide_mcp::Tool, trusted: bool) -> bool {
trusted || tool.read_only
}
impl Registry {
fn target(&self, root: &Path) -> Result<(String, Arc<crate::Project>), String> {
if self.0.mcp_reports.remote_roots().iter().any(|p| p == root) {
return Err("not supported for remote projects yet".into());
}
let mut windows: Vec<_> = self
.0
.projects
.read()
.iter()
.filter(|(_, p)| p.root == root && !p.light)
.map(|(w, p)| (w.clone(), p.clone()))
.collect();
let focused = self.0.mcp_reports.focused_window();
windows.sort_by(|a, b| {
(Some(&b.0) == focused.as_ref()).cmp(&(Some(&a.0) == focused.as_ref())).then(a.0.cmp(&b.0))
});
windows.into_iter().next().ok_or_else(|| "project is no longer open".into())
}
}
impl ToolRegistry for Registry {
fn projects(&self) -> Vec<PathBuf> {
self.0
.projects()
.into_iter()
.filter(|p| !p.light)
.map(|p| p.root.clone())
.chain(self.0.mcp_reports.remote_roots())
.collect()
}
fn tools(&self, root: &Path) -> Result<Vec<ide_mcp::Tool>, String> {
let (_, project) = self.target(root)?;
Ok(tools().into_iter().map(|t| t.metadata).filter(|t| allowed(t, project.is_trusted())).collect())
}
fn call(
&self,
root: &Path,
name: &str,
arguments: &str,
cancel: CancellationToken,
) -> Result<String, ToolCallError> {
self.call_with_client(root, name, arguments, cancel, "MCP client")
}
fn call_with_client(
&self,
root: &Path,
name: &str,
arguments: &str,
cancel: CancellationToken,
client: &str,
) -> Result<String, ToolCallError> {
let (window, project) = self.target(root).map_err(ToolCallError::Failed)?;
let tool = tools()
.into_iter()
.find(|t| t.metadata.name == name && allowed(&t.metadata, project.is_trusted()))
.ok_or_else(|| ToolCallError::UnknownTool(format!("unknown tool: {name}")))?;
let args = serde_json::from_str(arguments).map_err(|e| ToolCallError::Failed(e.to_string()))?;
let call = tool::Call {
host: self.0.clone(),
project,
window,
args,
cancel: cancel.clone(),
client: client.to_owned(),
tool: name.to_owned(),
};
if cancel.is_cancelled() {
return Err(ToolCallError::Failed(IdeError::cancelled().message));
}
let result = self.0.runtime.block_on((tool.handler)(call));
if cancel.is_cancelled() {
return Err(ToolCallError::Failed(IdeError::cancelled().message));
}
result.map(|out| out.0).map_err(|e| ToolCallError::Failed(e.message))
}
}
impl Host {
fn mcp_windows(&self, root: &Path) -> Vec<String> {
let mut windows: Vec<_> =
self.projects.read().iter().filter(|(_, p)| p.root == root).map(|(w, _)| w.clone()).collect();
windows.sort();
windows
}
}
#[cfg(test)]
mod tests {
#[test]
fn restricted_mode_excludes_execution_tools() {
let mut tool = super::tools().remove(0).metadata;
assert!(super::allowed(&tool, false));
tool.read_only = false;
assert!(!super::allowed(&tool, false));
assert!(super::allowed(&tool, true));
}
}
+124
View File
@@ -0,0 +1,124 @@
//! Window-owned snapshots. A project close drops every report from that window.
use ide_core::{Diagnostic, IpcPath, Range};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FileProblems {
pub path: IpcPath,
pub items: Vec<Diagnostic>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FocusedEditor {
pub path: IpcPath,
/// Zero-based lines and UTF-16 columns, end exclusive.
pub selection: Range,
pub selected_text: String,
}
#[derive(Default)]
pub struct Reports(RwLock<State>);
#[derive(Default)]
struct State {
windows: HashMap<String, Window>,
focused: Option<String>,
remote: HashMap<String, PathBuf>,
libraries: HashMap<String, Vec<PathBuf>>,
}
#[derive(Default)]
struct Window {
editors: Vec<PathBuf>,
focused: Option<FocusedEditor>,
problems: BTreeMap<PathBuf, Vec<Diagnostic>>,
}
impl Reports {
pub fn set_open_editors(&self, window: &str, paths: Vec<PathBuf>) {
self.0.write().windows.entry(window.into()).or_default().editors = paths;
}
pub fn set_focused_editor(&self, window: &str, editor: Option<FocusedEditor>, focused: bool) {
let mut state = self.0.write();
state.windows.entry(window.into()).or_default().focused = editor;
if focused {
state.focused = Some(window.into());
}
}
pub fn report_problems(&self, window: &str, reset: bool, files: Vec<FileProblems>) {
let mut state = self.0.write();
let problems = &mut state.windows.entry(window.into()).or_default().problems;
if reset {
problems.clear();
}
for file in files {
let path = file.path.into();
if file.items.is_empty() {
problems.remove(&path);
} else {
problems.insert(path, file.items);
}
}
}
pub fn forget_window(&self, window: &str) {
let mut state = self.0.write();
state.windows.remove(window);
state.remote.remove(window);
state.libraries.remove(window);
if state.focused.as_deref() == Some(window) {
state.focused = None;
}
}
pub fn set_remote(&self, window: &str, root: PathBuf) {
self.0.write().remote.insert(window.into(), root);
}
pub fn remove_remote(&self, window: &str) {
self.0.write().remote.remove(window);
}
pub(crate) fn set_libraries(&self, window: &str, roots: Vec<PathBuf>) {
self.0.write().libraries.insert(window.into(), roots);
}
pub(super) fn libraries(&self, windows: &[String]) -> Vec<PathBuf> {
let state = self.0.read();
windows.iter().filter_map(|w| state.libraries.get(w)).flatten().cloned().collect()
}
pub(super) fn remote_roots(&self) -> Vec<PathBuf> {
self.0.read().remote.values().cloned().collect()
}
pub(super) fn focused_window(&self) -> Option<String> {
self.0.read().focused.clone()
}
pub(super) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
let state = self.0.read();
let mut paths = Vec::new();
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
for path in &window.editors {
if !paths.contains(path) {
paths.push(path.clone());
}
}
}
paths
}
pub(super) fn focused(&self, window: &str) -> Option<FocusedEditor> {
self.0.read().windows.get(window).and_then(|w| w.focused.clone())
}
pub(super) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
let state = self.0.read();
let mut files: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
for (path, items) in &window.problems {
let found = files.entry(path.clone()).or_default();
for item in items {
if !found.contains(item) {
found.push(item.clone());
}
}
}
}
files.into_iter().map(|(path, items)| FileProblems { path: path.into(), items }).collect()
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Window-bound requests; missing windows, cancellation and timeout all fail closed.
use super::tool::Call;
use ide_core::{IdeError, IdeEvent, IdeResult, event::McpUiRequest};
use parking_lot::Mutex;
use std::collections::{HashMap, HashSet};
use tokio::sync::oneshot;
#[derive(Default)]
pub struct Requests(Mutex<State>);
#[derive(Default)]
struct State {
ready: HashSet<String>,
pending: HashMap<u32, Pending>,
}
struct Pending {
window: String,
answer: oneshot::Sender<Result<bool, String>>,
}
impl Requests {
pub fn set_ready(&self, window: &str, ready: bool) {
if ready {
self.0.lock().ready.insert(window.into());
} else {
self.forget_window(window);
}
}
pub fn forget_window(&self, window: &str) {
let mut state = self.0.lock();
state.ready.remove(window);
state.pending.retain(|_, p| p.window != window);
}
pub fn answer(&self, window: &str, id: u32, allow: bool, error: Option<String>) -> IdeResult<()> {
let mut state = self.0.lock();
if !state.pending.get(&id).is_some_and(|p| p.window == window) {
return Err(IdeError::invalid("MCP request expired or belongs to another window"));
}
let pending = state.pending.remove(&id).unwrap();
let _ = pending.answer.send(error.map_or(Ok(allow), Err));
Ok(())
}
pub(super) async fn ask(&self, call: &Call, request: McpUiRequest) -> IdeResult<()> {
let id = super::tool::request_id().0;
let (tx, rx) = oneshot::channel();
{
let mut state = self.0.lock();
if !state.ready.contains(call.window()) {
return Err(IdeError::unavailable("MCP request denied: no project window is available"));
}
state.pending.insert(id, Pending { window: call.window.clone(), answer: tx });
}
struct Close<'a>(&'a Requests, &'a Call, u32);
impl Drop for Close<'_> {
fn drop(&mut self) {
self.0.0.lock().pending.remove(&self.2);
self.1.project.sink.emit(IdeEvent::McpRequestClosed { id: self.2 });
}
}
let _close = Close(self, call, id);
call.project.sink.emit(IdeEvent::McpRequest { id, request });
let result = tokio::select! {
result = rx => result.map_err(|_| IdeError::unavailable("MCP request denied: project window closed"))?,
_ = tokio::time::sleep(std::time::Duration::from_secs(180)) => return Err(IdeError::unavailable("MCP request denied: confirmation timed out")),
_ = call.cancel.cancelled() => return Err(IdeError::cancelled()),
};
match result {
Ok(true) => Ok(()),
Ok(false) => Err(IdeError::invalid("MCP request denied by the user")),
Err(message) => Err(IdeError::unavailable(message)),
}
}
}
+214
View File
@@ -0,0 +1,214 @@
// Ported from JetBrains intellij-community plugins/mcp-server
// (mcpserver.toolsets/src/general/AnalysisToolset.kt, build_project), Apache-2.0.
//! `build_project`.
use super::output::{MAX_TEXT_LENGTH, TRUNCATED_MARKER, truncate_tail};
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_build::compile::{BuildEvent, BuildKind, BuildMessage, BuildResult};
use ide_core::Severity;
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use serde_json::{Map, Value, json};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
const DEFAULT_TIMEOUT_MS: usize = 60_000;
const MAX_TIMEOUT_MS: usize = 3_600_000;
const MAX_PROBLEMS: usize = 300;
/// Console lines attached to a failure whose messages were not parsed.
const CONSOLE_LINES: usize = 200;
/// A JetBrains `ProjectProblem`: `kind` ERROR or WARNING, the compiler's code as `group`,
/// 1-based `line` / `column` (JetBrains reports them 0-based).
fn problem(call: &Call, m: &BuildMessage) -> Value {
let mut v = json!({
"message": m.text,
"kind": if m.severity == Severity::Error { "ERROR" } else { "WARNING" },
});
if let Some(code) = &m.code {
v["group"] = code.clone().into();
}
if let Some(path) = &m.path {
v["file"] = call.display(path).into();
}
if let Some(range) = m.range {
v["line"] = (range.start.line + 1).into();
v["column"] = (range.start.col + 1).into();
}
v
}
/// Runs the build of `target` (the whole project when `None`), its console into `console`.
/// Returns `None` when `deadline` passed first (the build is then stopped).
pub async fn build_once(
call: &Call,
kind: BuildKind,
target: Option<PathBuf>,
deadline: Instant,
console: &Arc<Mutex<String>>,
) -> IdeResult<Option<BuildResult>> {
let host = call.host();
let request = crate::mcp::tool::request_id();
let sink = console.clone();
let channel = crate::ops::Channel::new(move |e: BuildEvent| match e {
BuildEvent::Output { text, .. } => sink.lock().push_str(&text),
BuildEvent::Step { command, .. } => sink.lock().push_str(&format!("$ {command}\n")),
_ => {}
});
let build = crate::ops::build::build_run(&host, call.window(), kind, target, None, request, Vec::new(), channel);
tokio::pin!(build);
let remaining = deadline.saturating_duration_since(Instant::now());
tokio::select! {
r = &mut build => r.map(Some),
_ = tokio::time::sleep(remaining) => {
host.cancel_request(request);
let _ = build.await;
Ok(None)
}
_ = call.cancel.cancelled() => {
host.cancel_request(request);
let _ = build.await;
Err(IdeError::cancelled())
}
}
}
pub fn build_project() -> Tool {
Tool::new(
"build_project",
"Triggers building of the project or specified files, waits for completion, and returns build errors.
Use this tool to build the project or compile files and get detailed information about compilation errors and warnings.
You have to use this tool after performing edits to validate if the edits are valid.
In OpenIDEA the project's build system builds it (Maven, Gradle, Cargo, Go or TypeScript); with `filesToRebuild`, the \
modules containing those files. Asks the user first for every call. Problems have the project-relative \
`file` and 1-based `line` / `column`; at most 300 problems are listed, and `isSuccess` is false whenever one of them \
is an ERROR. On timeout (at most 1 hour; 0 times out at once) the build is stopped.",
|call| async move {
let files: Vec<String> =
call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect();
let rebuild = call.flag("rebuild", false)? && files.is_empty();
let kind = if rebuild { BuildKind::Rebuild } else { BuildKind::Build };
let limit = timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?;
let mut targets: Vec<Option<PathBuf>> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
for f in &files {
let path = call.resolve_inside(f)?;
if !path.exists() {
return Err(IdeError::not_found(format!("File not found: {f}")));
}
let dir = if path.is_dir() { path.clone() } else { path.parent().unwrap_or(&path).to_path_buf() };
if !dirs.contains(&dir) {
dirs.push(dir);
targets.push(Some(path));
}
}
if targets.is_empty() {
targets.push(None);
}
let subject = if rebuild { "Rebuild project" } else { "Build project" };
let detail = targets.iter().flatten().map(|p| call.display(p)).collect::<Vec<_>>().join(", ");
call.confirm(subject, (!detail.is_empty()).then(|| format!("modules of {detail}"))).await?;
if limit.is_zero() {
// JetBrains' `withTimeoutOrNull(0)`: nothing is built.
return Output::json(json!({ "isSuccess": false, "problems": [], "timedOut": true }));
}
let console = Arc::new(Mutex::new(String::new()));
let deadline = Instant::now() + limit;
let mut results: Vec<BuildResult> = Vec::new();
let mut timed_out = false;
for target in targets {
match build_once(&call, kind, target, deadline, &console).await? {
Some(r) => {
let failed = !r.success;
results.push(r);
if failed {
break;
}
}
None => {
timed_out = true;
break;
}
}
}
let messages: Vec<&BuildMessage> = results
.iter()
.flat_map(|r| r.messages.iter())
.filter(|m| matches!(m.severity, Severity::Error | Severity::Warning))
.collect();
let mut problems: Vec<Value> = messages.iter().take(MAX_PROBLEMS).map(|m| problem(&call, m)).collect();
let has_errors = messages.iter().any(|m| m.severity == Severity::Error);
let failed = results.iter().any(|r| !r.success);
if failed && !has_errors {
let tail = truncate_tail(
console.lock().trim_end(),
CONSOLE_LINES,
MAX_TEXT_LENGTH,
TRUNCATED_MARKER,
);
problems.push(json!({
"message": "Build reported errors, but detailed error messages were not captured through build events.",
"kind": "ERROR",
"description": tail,
}));
}
let mut result = Map::new();
result.insert("isSuccess".into(), is_success(timed_out, &results).into());
result.insert("problems".into(), problems.into());
if timed_out {
result.insert("timedOut".into(), true.into());
}
Output::json(Value::Object(result))
},
)
.write()
.param(Param::optional(
"rebuild",
ParamType::Boolean,
"Whether to perform full rebuild the project. Defaults to false. Effective only when `filesToRebuild` is not \
specified.",
))
.param(Param::optional(
"filesToRebuild",
ParamType::StringList,
"If specified, only compile files with the specified paths. Paths are relative to the project root.",
))
.param(Param::optional("timeout", ParamType::Integer, "Timeout in milliseconds"))
}
/// JetBrains' `isSuccess`: finished in time, every build succeeded and no ERROR was reported
/// (a build tool may exit 0 after printing errors).
fn is_success(timed_out: bool, results: &[BuildResult]) -> bool {
!timed_out && results.iter().all(|r| r.success && r.messages.iter().all(|m| m.severity != Severity::Error))
}
#[cfg(test)]
mod tests {
use super::*;
fn result(success: bool, severities: &[Severity]) -> BuildResult {
BuildResult {
system: ide_build::compile::BuildSystem::Cargo,
title: "build".into(),
success,
errors: 0,
warnings: 0,
duration_ms: 0.0,
messages: severities
.iter()
.map(|s| BuildMessage { severity: *s, text: "m".into(), path: None, range: None, code: None })
.collect(),
}
}
#[test]
fn an_error_problem_fails_the_build() {
assert!(is_success(false, &[result(true, &[Severity::Warning])]));
assert!(!is_success(false, &[result(true, &[Severity::Warning, Severity::Error])]));
assert!(!is_success(false, &[result(false, &[])]));
assert!(!is_success(true, &[result(true, &[])]));
}
}
+709
View File
@@ -0,0 +1,709 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/ExecutionToolset.kt,
// mcpserver.toolsets/src/util/execution.util.kt, mcpserver.toolsets/src/util/RunConfigurationBuildErrors.kt), Apache-2.0.
//! `get_run_configurations` and `execute_run_configuration`.
use super::build::build_once;
use super::output::OutputCollector;
use super::process::{quote, split_words};
use super::targets;
use crate::Project;
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_build::compile::BuildKind;
use ide_core::{IdeError, IdeResult, RunId, Severity};
use ide_run::{OutputChunk, RunConfig};
use parking_lot::Mutex;
use serde_json::{Map, Value, json};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 10_000;
/// No limit in JetBrains; the process keeps running after the timeout anyway.
const MAX_TIMEOUT_MS: usize = 1_800_000;
/// Output of a failed before-launch process kept for the error message.
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
/// A before-launch build gets this long.
const BEFORE_LAUNCH_BUILD_TIMEOUT: Duration = Duration::from_secs(3600);
async fn load_configs(call: &Call) -> IdeResult<Vec<RunConfig>> {
let host = call.host();
let window = call.window().to_owned();
let h = host.clone();
host.blocking(move || crate::ops::run::configs(&h, &window)).await
}
fn supports_overrides(config: &RunConfig) -> bool {
config.extras.compound.is_empty() && !config.command.trim().is_empty()
}
fn command_line(config: &RunConfig) -> String {
std::iter::once(config.command.clone()).chain(config.args.iter().map(|a| quote(a))).collect::<Vec<_>>().join(" ")
}
fn describe_config(call: &Call, c: &RunConfig) -> Value {
let mut v = json!({
"name": c.name,
"description": c.kind,
"supportsDynamicLaunchOverrides": supports_overrides(c),
});
if supports_overrides(c) {
v["commandLine"] = command_line(c).into();
if let Some(cwd) = c.cwd.as_deref() {
v["workingDirectory"] = call.display(&call.root().join(cwd)).into();
}
if !c.env.is_empty() {
v["environment"] = json!(c.env);
}
}
v
}
/// A file of the project to look for run points in: its `/`-separated project-relative path
/// and current text (unsaved edits included).
fn read_file(project: &Project, path: &Path, file: &str) -> IdeResult<(String, String)> {
let meta = std::fs::metadata(path).map_err(|_| IdeError::not_found(format!("File not found: {file}")))?;
if !meta.is_file() {
return Err(IdeError::invalid(format!("Not a file: {file}")));
}
let rel = path.strip_prefix(&project.root).unwrap_or(path).to_string_lossy().replace('\\', "/");
let text = match project.documents.text_of(path) {
Some(text) => text,
None => std::fs::read_to_string(path).map_err(|e| IdeError::invalid(format!("Cannot read {file}: {e}")))?,
};
Ok((rel, text))
}
/// Reads `file` and runs `scan` (root, relative path, text) over it off the async threads:
/// the run point patterns go over up to 400 KB of text.
async fn scan_file<T: Send + 'static>(
call: &Call,
file: &str,
scan: impl FnOnce(&Path, &str, &str) -> IdeResult<T> + Send + 'static,
) -> IdeResult<T> {
let path = call.resolve_inside(file)?;
let project = call.project().clone();
let file = file.to_owned();
call.host()
.blocking(move || {
let (rel, text) = read_file(&project, &path, &file)?;
scan(&project.root, &rel, &text)
})
.await
}
pub fn get_run_configurations() -> Tool {
Tool::new(
"get_run_configurations",
"Returns either project run configurations or executable code locations, depending on the input.
Without `filePath`, this tool lists the project's existing run configurations. The result includes configuration
names and, when available, launch details such as program arguments, working directory, environment variables,
and `supportsDynamicLaunchOverrides`.
`supportsDynamicLaunchOverrides` is the source-of-truth capability flag for one-time launch overrides
(`programArguments`, `workingDirectory`, `envs`) in `execute_run_configuration` and `xdebug_start_debugger_session`.
Only pass those override parameters when this flag is `true` for the selected configuration.
With `filePath`, this tool discovers executable entry points (run points) in that file, such as test methods,
main methods, or other executable entry points where the IDE shows a Run gutter icon. The result contains `filePath` and
`runPoints`; use the returned line numbers with `execute_run_configuration` to run from code.
In OpenIDEA the configurations are the saved ones and those detected from the build files (package.json scripts, \
Cargo targets, Gradle/Maven tasks, Go, Python, Makefile…); `description` is the configuration type and `commandLine` \
the whole command. Run points are found in Rust, JavaScript/TypeScript, Python and Go files.",
|call| async move {
let configs = load_configs(&call).await?;
if let Some(file) = call.opt_str("filePath")? {
let points = scan_file(&call, &file, move |root, rel, text| {
let mut points: Vec<Value> = Vec::new();
let mut lines = std::collections::HashSet::new();
for t in targets::detect(text, rel) {
if lines.contains(&t.line) {
continue;
}
if let Some(config) = targets::target_config(root, &t, rel, &configs, text) {
lines.insert(t.line);
points.push(json!({
"line": t.line + 1,
"description": format!("Run '{}'", config.name),
"elementText": t.name,
}));
}
}
Ok(points)
})
.await?;
return Output::json(json!({ "filePath": file, "runPoints": points }));
}
let list: Vec<Value> = configs.iter().map(|c| describe_config(&call, c)).collect();
Output::json(json!({ "configurations": list }))
},
)
.write()
.param(Param::optional(
"filePath",
ParamType::String,
"Optional file path relative to the project root. When provided, returns run points (executable entry points) \
in the file instead of project-wide run configurations.",
))
}
/// What `execute_run_configuration` runs.
enum ExecutionTarget {
ByName(String),
ByContext(String, i64),
}
fn execution_target(name: Option<String>, file: Option<String>, line: Option<i64>) -> IdeResult<ExecutionTarget> {
let name = name.filter(|n| !n.trim().is_empty());
let file = file.filter(|f| !f.trim().is_empty());
match (name, file, line) {
(Some(_), f, l) if f.is_some() || l.is_some() => {
Err(IdeError::invalid("Pass either configurationName or filePath + line, but not both."))
}
(Some(name), _, _) => Ok(ExecutionTarget::ByName(name)),
(None, Some(file), Some(line)) => Ok(ExecutionTarget::ByContext(file, line)),
(None, Some(_), None) | (None, None, Some(_)) => {
Err(IdeError::invalid("Pass both filePath and line together, or use configurationName."))
}
(None, None, None) => Err(IdeError::invalid("Pass either configurationName or filePath + line.")),
}
}
/// The temporary configuration of the run point at `file:line` (1-based): the innermost target
/// enclosing that line (as JetBrains walks up to the enclosing test), else the whole file's
/// (its tests, its main function).
async fn config_from_code_location(
call: &Call,
configs: Vec<RunConfig>,
file: &str,
line: i64,
) -> IdeResult<RunConfig> {
let file_name = file.to_owned();
scan_file(call, file, move |root, rel, text| {
if line < 1 {
return Err(IdeError::invalid(format!("Line number must be >= 1, got: {line}")));
}
let count = text.split('\n').count();
if line as usize > count {
return Err(IdeError::invalid(format!("Line {line} is out of range (file has {count} lines)")));
}
targets::enclosing(text, rel, line as usize - 1)
.iter()
.find_map(|t| targets::target_config(root, t, rel, &configs, text))
.or_else(|| targets::context_config(root, rel, &configs, text))
.ok_or_else(|| {
IdeError::not_found(format!(
"No run configuration could be created from {file_name}:{line}. Use get_run_configurations with \
filePath to find valid run locations."
))
})
})
.await
}
/// Applies the one-off overrides (JetBrains' `prepareRunConfigurationForExecution`): a missing or
/// empty string keeps the value, a whitespace-only one clears it; `envs` merge over the configuration's.
fn apply_overrides(
config: &mut RunConfig,
root: &Path,
args: Option<String>,
cwd: Option<String>,
envs: Option<Vec<(String, String)>>,
) -> IdeResult<()> {
let args = args.filter(|a| !a.is_empty());
let cwd = cwd.filter(|d| !d.is_empty());
if args.is_none() && cwd.is_none() && envs.is_none() {
return Ok(());
}
if !supports_overrides(config) {
return Err(IdeError::invalid(format!(
"Run configuration '{}' of type '{}' doesn't support dynamic launch overrides (programArguments, \
workingDirectory, envs).",
config.name,
if config.extras.compound.is_empty() { config.kind.as_str() } else { "Compound" }
)));
}
if let Some(args) = args {
let words = split_words(&args)?;
// A configuration that is one shell line (`npm run dev`, no separate arguments)
// keeps its command and gets the new line's arguments appended.
let shell_line = config.args.is_empty() && config.command.trim().contains(char::is_whitespace);
if shell_line {
if !words.is_empty() {
config.command = format!("{} {}", config.command.trim(), args.trim());
}
} else {
config.args = words;
}
}
if let Some(dir) = cwd {
if dir.trim().is_empty() {
config.cwd = None;
} else {
let path = crate::mcp::tool::resolve_inside(root, &dir)?;
if !path.is_dir() {
return Err(IdeError::not_found(format!("workingDirectory {} is not a directory", path.display())));
}
config.cwd = Some(path.into());
}
}
config.env.extend(envs.into_iter().flatten());
Ok(())
}
/// Polls how run `id` ended: `None` while running, else its exit code (if known).
fn run_exit(host: &crate::Host, id: RunId) -> Option<Option<i32>> {
match host.processes.exit_code(id) {
Ok(exit) => exit,
// Remote (SSH) runs, or a finished run already forgotten: the run list knows whether it runs.
Err(_) => match crate::ops::run::list(host).into_iter().find(|r| r.id == id) {
Some(r) if r.running => None,
_ => Some(None),
},
}
}
/// Waits for run `id` to exit, until `deadline`: `None` when it is still running. Cancelling
/// the call stops the run.
async fn wait_exit(call: &Call, id: RunId, deadline: Option<Instant>) -> IdeResult<Option<Option<i32>>> {
let host = call.host();
loop {
if let Some(exit) = run_exit(&host, id) {
return Ok(Some(exit));
}
if deadline.is_some_and(|d| Instant::now() >= d) {
return Ok(None);
}
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
_ = call.cancel.cancelled() => {
let _ = crate::ops::run::stop(&host, call.window(), id);
return Err(IdeError::cancelled());
}
}
}
}
/// An output callback that skips the IDE's own lines (`Process finished…`), as JetBrains
/// skips `ProcessOutputType.SYSTEM`.
fn process_output(mut f: impl FnMut(&str) + Send + 'static) -> crate::ops::Emit<OutputChunk> {
let f = Mutex::new(move |t: &str| f(t));
Arc::new(move |chunk: OutputChunk| match &chunk {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } => (f.lock())(text),
OutputChunk::System { .. } => {}
})
}
/// What failed before launch (JetBrains' `RunConfigurationBuildErrors`): build errors as
/// `path:line: message`, else the output of before-launch processes that failed.
#[derive(Default)]
struct BuildErrors {
errors: Vec<String>,
process_outputs: Vec<String>,
}
impl BuildErrors {
fn text(&self) -> Option<String> {
let mut seen = std::collections::HashSet::new();
let errors: Vec<&String> = self.errors.iter().filter(|e| seen.insert(*e)).collect();
if !errors.is_empty() {
return Some(errors.iter().map(|e| e.as_str()).collect::<Vec<_>>().join("\n"));
}
let outputs: Vec<&str> = self.process_outputs.iter().map(|o| o.trim()).filter(|o| !o.is_empty()).collect();
(!outputs.is_empty()).then(|| outputs.join("\n"))
}
}
/// Runs `config` to its end; `Ok(false)` (with its output recorded) when it failed.
async fn run_to_end(call: &Call, config: RunConfig, errors: &mut BuildErrors) -> IdeResult<bool> {
let tail = Arc::new(Mutex::new(String::new()));
let sink = tail.clone();
let emit = process_output(move |text| {
let mut t = sink.lock();
t.push_str(text);
if t.len() > 2 * MAX_PROCESS_OUTPUT_LENGTH {
let mut cut = t.len() - MAX_PROCESS_OUTPUT_LENGTH;
while !t.is_char_boundary(cut) {
cut += 1;
}
t.drain(..cut);
}
});
let info = crate::ops::run::start(&call.host(), call.window(), config, emit).await?;
let exit = wait_exit(call, info.id, None).await?.flatten();
if exit == Some(0) {
return Ok(true);
}
let t = tail.lock();
let start = t.char_indices().rev().nth(MAX_PROCESS_OUTPUT_LENGTH - 1).map_or(0, |(i, _)| i);
errors.process_outputs.push(t[start..].to_owned());
Ok(false)
}
/// The configuration's before-launch tasks, as the Run tool window runs them; returns why
/// the launch is aborted, if it is. Tool windows are not activated.
async fn before_launch(
call: &Call,
config: &RunConfig,
configs: &[RunConfig],
chain: &mut Vec<String>,
errors: &mut BuildErrors,
) -> IdeResult<Option<String>> {
chain.push(config.id.clone());
for task in &config.extras.before_launch {
let target = task.target.as_str();
let (label, failure) = match task.kind.as_str() {
"build" => {
let console = Arc::new(Mutex::new(String::new()));
let deadline = Instant::now() + BEFORE_LAUNCH_BUILD_TIMEOUT;
let failure = match build_once(call, BuildKind::Build, None, deadline, &console).await {
Ok(Some(r)) => {
for m in r.messages.iter().filter(|m| m.severity == Severity::Error) {
errors.errors.push(match (&m.path, m.range) {
(Some(p), Some(r)) => format!("{}:{}: {}", p.display(), r.start.line + 1, m.text),
_ => m.text.clone(),
});
}
(!r.success).then(|| "Build failed".to_owned())
}
Ok(None) => Some("The build timed out".to_owned()),
Err(e) => Some(e.message),
};
("Build Project".to_owned(), failure)
}
"run" => {
let other = configs.iter().find(|c| c.id == target);
let label = format!("Run '{}'", other.map_or(target, |c| c.name.as_str()));
let failure = if chain.iter().any(|c| c == target) {
Some("The before launch tasks run in a cycle".to_owned())
} else if let Some(other) = other {
let ok = Box::pin(before_launch(call, other, configs, chain, errors)).await?.is_none()
&& run_to_end(call, other.clone(), errors).await?;
(!ok).then(|| format!("'{}' failed", other.name))
} else {
Some(format!("Configuration '{target}' no longer exists"))
};
(label, failure)
}
"command" => {
let failure = if target.trim().is_empty() {
Some("No command".to_owned())
} else {
let run = RunConfig {
id: format!("before-launch:{}:{target}", config.id),
name: target.to_owned(),
kind: "shell".into(),
command: target.to_owned(),
args: Vec::new(),
cwd: config.cwd.clone(),
env: Default::default(),
detected: true,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
(!run_to_end(call, run, errors).await?).then(|| format!("'{target}' failed"))
};
(format!("Run '{target}'"), failure)
}
"toolWindow" => continue,
"tool" => (format!("External tool '{target}'"), Some("External tools cannot run through MCP".to_owned())),
other => (other.to_owned(), Some(format!("Unknown before launch task '{other}'"))),
};
if let Some(failure) = failure {
chain.pop();
return Ok(Some(format!("Before launch: {label}: {failure}")));
}
}
chain.pop();
Ok(None)
}
/// The before-launch tasks of `config` as the confirmation lists them, nested ones indented.
fn describe_before_launch(
config: &RunConfig,
configs: &[RunConfig],
depth: usize,
chain: &mut Vec<String>,
out: &mut String,
) {
chain.push(config.id.clone());
let indent = " ".repeat(depth + 1);
for task in &config.extras.before_launch {
let target = task.target.as_str();
match task.kind.as_str() {
"build" => out.push_str(&format!("\n{indent}Build Project")),
"command" => out.push_str(&format!("\n{indent}Run command: {target}")),
"run" => match configs.iter().find(|c| c.id == target) {
Some(other) => {
let line = if other.extras.compound.is_empty() { command_line(other) } else { "compound".into() };
out.push_str(&format!("\n{indent}Run '{}': {line}", other.name));
if !chain.contains(&other.id) {
describe_before_launch(other, configs, depth + 1, chain, out);
}
}
None => out.push_str(&format!("\n{indent}Run '{target}' (missing)")),
},
"toolWindow" => {}
"tool" => out.push_str(&format!("\n{indent}External tool '{target}'")),
other => out.push_str(&format!("\n{indent}{other} '{target}'")),
}
}
chain.pop();
}
/// What the user approves for `config` besides its command line: everything else that runs
/// or applies — before-launch tasks, the stored environment (for a configuration made from
/// code, the one of the configuration it derives from), `.env` files, the `envs` overrides
/// (`NODE_OPTIONS`, `PATH`, ...), the stop command and a remote run target.
fn confirmation_detail(
config: &RunConfig,
cwd: &str,
stored_env: &std::collections::BTreeMap<String, String>,
overrides: &[(String, String)],
configs: &[RunConfig],
) -> String {
let mut detail = command_line(config);
if !cwd.is_empty() {
detail.push_str(&format!("\nin {cwd}"));
}
if let Some(target) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
detail.push_str(&format!("\non run target {target}"));
}
let mut tasks = String::new();
describe_before_launch(config, configs, 0, &mut Vec::new(), &mut tasks);
if !tasks.is_empty() {
detail.push_str(&format!("\nbefore launch:{tasks}"));
}
if !config.extras.env_files.is_empty() {
detail.push_str(&format!("\nenv files: {}", config.extras.env_files.join(", ")));
}
if !config.extras.include_system_env {
detail.push_str("\nwithout the system environment");
}
if !stored_env.is_empty() {
detail.push_str("\nenvironment:");
for (k, v) in stored_env {
detail.push_str(&format!("\n {k}={v}"));
}
}
if !overrides.is_empty() {
detail.push_str("\nenvironment overrides:");
for (k, v) in overrides {
detail.push_str(&format!("\n {k}={v}"));
}
}
if let Some(stop) = config.extras.stop_command.as_deref().filter(|s| !s.trim().is_empty()) {
detail.push_str(&format!("\non stop: {stop}"));
}
detail
}
fn execution_failed(details: &[Option<String>]) -> IdeError {
let details: Vec<&str> = details.iter().flatten().map(String::as_str).collect();
IdeError::invalid(format!("Execution failed: {}", details.join("\n")))
}
pub fn execute_run_configuration() -> Tool {
Tool::new(
"execute_run_configuration",
"Run either an existing run configuration by name or a temporary run configuration created from a code location \
(`filePath` + `line`) in the current project, then wait up to specified timeout for it to finish.
Use this tool with either a configuration name returned by `get_run_configurations`, or with a run point
(`filePath` + `line`) returned by `get_run_configurations(filePath = ...)`.
Optional launch overrides (`programArguments`, `workingDirectory`, `envs`) are applied only for this run and are not persisted.
Do not pass these override parameters unless you explicitly need to change the configured launch values for this run.
Missing/null override parameters keep existing run configuration values unchanged.
For string overrides (`programArguments`, `workingDirectory`), missing/null or empty string (`\"\"`) keeps the existing value unchanged.
Pass a whitespace-only string such as `\" \"` to clear an existing value for this launch.
Pass either `configurationName`, or `filePath` together with `line`. These modes are mutually exclusive.
Behavior:
- When `waitForExit=true`, waits up to `timeout` milliseconds for process termination. If the timeout expires,
the process keeps running in the background and `exitCode` is omitted from the result.
- When `waitForExit=false`, waits only for the process to start, then returns immediately without applying `timeout`.
- `fullOutputPath` points to a temp file with the full raw output and may continue growing while the process is alive.
Returns the execution result including current output snapshot, optional exit code, and optional `fullOutputPath`.
In OpenIDEA the user is asked first for every call; `programArguments` replace the configuration's \
arguments (appended to a command that is one shell line, such as `npm run dev`); the run does not appear in the Run \
tool window; output files are private and deleted after a day.",
|call| async move {
let target = execution_target(
call.opt_str("configurationName")?,
call.opt_str("filePath")?,
call.opt_i64("line")?,
)?;
let wait = call.flag("waitForExit", true)?;
// As JetBrains: `timeout` is ignored without waiting, and 0 returns without waiting.
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?) } else { None };
let envs = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
let configs = load_configs(&call).await?;
let mut config = match &target {
ExecutionTarget::ByName(name) => configs
.iter()
.find(|c| c.name == *name)
.cloned()
.ok_or_else(|| IdeError::not_found(format!("Run configuration with name '{name}' not found.")))?,
ExecutionTarget::ByContext(file, line) => {
config_from_code_location(&call, configs.clone(), file, *line).await?
}
};
let stored_env = config.env.clone();
let overrides: Vec<(String, String)> = envs.clone().unwrap_or_default();
apply_overrides(
&mut config,
call.root(),
call.opt_str("programArguments")?,
call.opt_str("workingDirectory")?,
envs,
)?;
if !config.extras.compound.is_empty() {
return Err(IdeError::invalid(format!(
"`{}` is a compound configuration, which cannot run through MCP; run its configurations ({}) \
one by one",
config.name,
config.extras.compound.join(", ")
)));
}
let cwd = match config.cwd.as_deref() {
Some(p) => call.root().join(p),
None => call.root().to_path_buf(),
};
let detail = confirmation_detail(&config, &call.display(&cwd), &stored_env, &overrides, &configs);
call.confirm(&config.name, Some(detail)).await?;
let mut errors = BuildErrors::default();
if let Some(reason) = before_launch(&call, &config, &configs, &mut Vec::new(), &mut errors).await? {
return Err(execution_failed(&[errors.text(), Some(reason)]));
}
let collector = Arc::new(Mutex::new(OutputCollector::create(&config.name)?));
let output_path = collector.lock().path.clone();
let sink = collector.clone();
let emit = process_output(move |text| sink.lock().append(text));
let info = match crate::ops::run::start(&call.host(), call.window(), config, emit).await {
Ok(info) => info,
Err(e) => {
let _ = std::fs::remove_file(&output_path);
return Err(execution_failed(&[errors.text(), Some(e.message)]));
}
};
let exit = match limit {
Some(limit) if !limit.is_zero() => wait_exit(&call, info.id, Some(Instant::now() + limit)).await?,
_ => None,
};
let c = collector.lock();
let mut result = Map::new();
if let Some(Some(code)) = exit {
result.insert("exitCode".into(), code.into());
}
result.insert("output".into(), c.preview().into());
// An exited run whose whole output is in `output` needs no file.
if exit.is_none() || c.truncated {
result.insert("fullOutputPath".into(), c.path.to_string_lossy().into_owned().into());
} else {
let _ = std::fs::remove_file(&c.path);
}
Output::json(Value::Object(result))
},
)
.write()
.param(Param::optional("configurationName", ParamType::String, "Name of the existing run configuration to execute"))
.param(Param::optional(
"filePath",
ParamType::String,
"File path relative to the project root. Provide together with `line` to create and execute a temporary run \
configuration from code context.",
))
.param(Param::optional(
"line",
ParamType::Integer,
"1-based line number for `filePath`. Provide together with `filePath` and do not combine with \
`configurationName`.",
))
.param(Param::optional("timeout", ParamType::Integer, "Timeout in milliseconds"))
.param(Param::optional(
"waitForExit",
ParamType::Boolean,
"Whether to wait for process termination. If false, the tool returns immediately after the process starts and \
ignores `timeout`.",
))
.param(Param::optional(
"programArguments",
ParamType::String,
"Optional program arguments override for this launch only. Missing/null or empty string keeps the existing \
value; whitespace-only string clears it.",
))
.param(Param::optional(
"workingDirectory",
ParamType::String,
"Optional working directory override for this launch only. Missing/null or empty string keeps the existing \
value; whitespace-only string clears it.",
))
.param(Param::optional(
"envs",
ParamType::StringMap,
"Optional environment variable overrides for this launch only. Missing/null keeps existing env unchanged; when \
provided, values are merged over existing env.",
))
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> RunConfig {
RunConfig {
id: "user:x".into(),
name: "x".into(),
kind: "shell".into(),
command: "prog".into(),
args: vec!["a".into()],
cwd: Some(std::path::PathBuf::from("sub").into()),
env: [("A".to_owned(), "1".to_owned())].into(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
}
}
#[test]
fn empty_overrides_keep_and_blank_ones_clear() {
let root = Path::new("/");
let mut c = config();
apply_overrides(&mut c, root, Some(String::new()), Some(String::new()), None).unwrap();
assert_eq!(c, config());
apply_overrides(&mut c, root, Some(" ".into()), Some(" ".into()), Some(vec![("C".into(), "3".into())]))
.unwrap();
assert!(c.args.is_empty());
assert!(c.cwd.is_none());
assert_eq!(c.env.keys().collect::<Vec<_>>(), ["A", "C"]);
apply_overrides(&mut c, root, Some("x 'y z'".into()), None, None).unwrap();
assert_eq!(c.args, ["x", "y z"]);
}
#[test]
fn execution_targets_are_exclusive() {
let err = |n: Option<&str>, f: Option<&str>, l| {
execution_target(n.map(Into::into), f.map(Into::into), l).err().map(|e| e.message)
};
assert_eq!(
err(Some("a"), Some("f"), Some(1)).as_deref(),
Some("Pass either configurationName or filePath + line, but not both.")
);
assert_eq!(
err(None, Some("f"), None).as_deref(),
Some("Pass both filePath and line together, or use configurationName.")
);
assert_eq!(err(None, None, None).as_deref(), Some("Pass either configurationName or filePath + line."));
assert!(err(Some("a"), None, None).is_none());
}
}
+25
View File
@@ -0,0 +1,25 @@
//! Run configurations and builds; every call asks the user first.
mod build;
mod configs;
mod output;
mod process;
mod targets;
use crate::mcp::tool::{Call, Tool};
use ide_core::IdeResult;
use std::time::Duration;
pub fn tools() -> Vec<Tool> {
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()]
}
/// The `timeout` argument: `default` when absent, at most `max` milliseconds. As in JetBrains
/// (`withTimeoutOrNull`), zero or a negative value times out at once.
fn timeout(call: &Call, default: usize, max: usize) -> IdeResult<Duration> {
Ok(match call.opt_i64("timeout")? {
None => Duration::from_millis(default as u64),
Some(n) if n <= 0 => Duration::ZERO,
Some(n) => Duration::from_millis((n as u64).min(max as u64)),
})
}
+188
View File
@@ -0,0 +1,188 @@
// Ported from JetBrains intellij-community plugins/mcp-server (src/com/intellij/mcpserver/util/text.util.kt,
// mcpserver.toolsets/src/util/OutputCollector.kt), Apache-2.0.
//! Output of commands and runs: truncation, the run output collector and the private
//! directory of full output files.
use ide_core::IdeResult;
use std::io::Write as _;
use std::path::{Path, PathBuf};
/// Marker of text cut by [`truncate_tail`].
pub const TRUNCATED_MARKER: &str = "<<<...content truncated...>>>";
/// Longest text [`truncate_tail`] returns (before its marker).
pub const MAX_TEXT_LENGTH: usize = 64 * 1024;
/// Characters of a run's output returned inline.
pub const RUN_PREVIEW_MAX_LENGTH: usize = 10_000;
pub const RUN_PREVIEW_TRUNCATED_MARKER: &str = "<truncated>";
/// Console tail bounded by line count and character count.
pub fn truncate_tail(text: &str, max_lines: usize, max_length: usize, marker: &str) -> String {
let lines: Vec<&str> = text.split('\n').collect();
let text = if lines.len() > max_lines {
[vec![marker], lines[lines.len() - (max_lines - 1)..].to_vec()].concat().join("\n")
} else {
text.to_owned()
};
if text.chars().count() <= max_length {
return text;
}
let start = text.char_indices().rev().nth(max_length - 1).map_or(0, |(i, _)| i);
format!("{marker}{}", &text[start..])
}
/// A run's output: all of it to a file, its first [`RUN_PREVIEW_MAX_LENGTH`] characters
/// in memory (then [`RUN_PREVIEW_TRUNCATED_MARKER`]).
pub struct OutputCollector {
pub path: PathBuf,
preview: String,
preview_chars: usize,
pub truncated: bool,
file: Option<std::fs::File>,
}
impl OutputCollector {
pub fn create(name: &str) -> IdeResult<Self> {
let (path, file) = temp_output_file(&format!("run-{name}"))?;
Ok(Self { path, preview: String::new(), preview_chars: 0, truncated: false, file: Some(file) })
}
pub fn append(&mut self, text: &str) {
// A failed write keeps the preview going; the file is then incomplete.
if let Some(f) = &mut self.file
&& f.write_all(text.as_bytes()).is_err()
{
self.file = None;
}
if self.truncated {
return;
}
// As JetBrains: reaching the limit marks the preview truncated, even without more output.
let room = RUN_PREVIEW_MAX_LENGTH - self.preview_chars;
let chars = text.chars().count();
if chars < room {
self.preview_chars += chars;
self.preview.push_str(text);
} else {
let cut = text.char_indices().nth(room).map_or(text.len(), |(i, _)| i);
self.preview.push_str(&text[..cut]);
self.preview.push_str(RUN_PREVIEW_TRUNCATED_MARKER);
self.preview_chars = RUN_PREVIEW_MAX_LENGTH;
self.truncated = true;
}
}
pub fn preview(&self) -> &str {
&self.preview
}
}
/// Output files older than this are removed when a new one is created.
const OUTPUT_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
pub fn temp_output_file(prefix: &str) -> IdeResult<(PathBuf, std::fs::File)> {
let dir = output_dir()?;
remove_old_logs(&dir, OUTPUT_MAX_AGE);
let safe: String =
prefix.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).take(40).collect();
let path = dir.join(format!("{safe}-{}.log", uuid::Uuid::new_v4().simple()));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let file = options.open(&path)?;
Ok((path, file))
}
/// Best effort: removes `*.log` files in `dir` last modified more than `max_age` ago.
fn remove_old_logs(dir: &Path, max_age: std::time::Duration) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let now = std::time::SystemTime::now();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "log") {
continue;
}
let old = entry
.metadata()
.ok()
.filter(std::fs::Metadata::is_file)
.and_then(|m| m.modified().ok())
.and_then(|modified| now.duration_since(modified).ok())
.is_some_and(|age| age > max_age);
if old {
let _ = std::fs::remove_file(&path);
}
}
}
/// A directory only this user can read: output may hold secrets, and `/tmp` is shared on Linux.
fn output_dir() -> IdeResult<PathBuf> {
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
let uid = rustix::process::getuid().as_raw();
let dir = std::env::temp_dir().join(format!("openidea-mcp-{uid}"));
match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e.into()),
}
let meta = std::fs::symlink_metadata(&dir)?;
if !meta.is_dir() || meta.uid() != uid || meta.mode() & 0o077 != 0 {
return Err(ide_core::IdeError::new(
ide_core::error::ErrorKind::PermissionDenied,
format!("{} is not a private directory of this user", dir.display()),
));
}
Ok(dir)
}
#[cfg(not(unix))]
{
let dir = std::env::temp_dir().join("openidea-mcp");
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn preview_line_keeps_a_fixed_length() {
// JetBrains' `truncateRunConfigurationPreviewLine`: 1000 characters including the marker.
let line = "a".repeat(2000);
let cut = truncate_tail(&line, 3, 1000 - RUN_PREVIEW_TRUNCATED_MARKER.len(), RUN_PREVIEW_TRUNCATED_MARKER);
assert!(cut.starts_with("<truncated>"));
assert_eq!(cut.len(), 1000);
}
#[test]
fn collector_limits_the_preview_and_keeps_everything_in_the_file() {
let mut c = OutputCollector::create("test").unwrap();
let output = "a".repeat(RUN_PREVIEW_MAX_LENGTH + 1);
c.append(&output[..10]);
c.append(&output[10..]);
assert_eq!(c.preview(), format!("{}{RUN_PREVIEW_TRUNCATED_MARKER}", &output[..RUN_PREVIEW_MAX_LENGTH]));
assert!(c.truncated);
assert_eq!(std::fs::read_to_string(&c.path).unwrap(), output);
let _ = std::fs::remove_file(&c.path);
}
#[test]
fn removes_day_old_logs() {
let dir = tempfile::tempdir().unwrap();
let old = std::time::SystemTime::now() - Duration::from_secs(25 * 60 * 60);
for name in ["old.log", "new.log", "old.txt"] {
let file = std::fs::File::create(dir.path().join(name)).unwrap();
if name.starts_with("old") {
file.set_modified(old).unwrap();
}
}
remove_old_logs(dir.path(), OUTPUT_MAX_AGE);
assert!(!dir.path().join("old.log").exists());
assert!(dir.path().join("new.log").exists());
assert!(dir.path().join("old.txt").exists(), "only logs are removed");
}
}
+65
View File
@@ -0,0 +1,65 @@
use ide_core::{IdeError, IdeResult};
/// POSIX-like word splitting: whitespace separates, quotes group, `\` escapes (not inside `'…'`).
pub fn split_words(line: &str) -> IdeResult<Vec<String>> {
let mut words = Vec::new();
let mut word = String::new();
let mut in_word = false;
let mut chars = line.chars();
while let Some(c) = chars.next() {
match c {
'\'' => {
in_word = true;
loop {
match chars.next() {
Some('\'') => break,
Some(c) => word.push(c),
None => return Err(IdeError::invalid("unterminated ' quote")),
}
}
}
'"' => {
in_word = true;
loop {
match chars.next() {
Some('"') => break,
Some('\\') => match chars.next() {
Some(c @ ('"' | '\\' | '$' | '`')) => word.push(c),
Some(c) => {
word.push('\\');
word.push(c);
}
None => return Err(IdeError::invalid("unterminated \" quote")),
},
Some(c) => word.push(c),
None => return Err(IdeError::invalid("unterminated \" quote")),
}
}
}
'\\' if !cfg!(windows) => {
in_word = true;
if let Some(c) = chars.next() {
word.push(c);
}
}
c if c.is_whitespace() => {
if in_word {
words.push(std::mem::take(&mut word));
in_word = false;
}
}
c => {
in_word = true;
word.push(c);
}
}
}
if in_word {
words.push(word);
}
Ok(words)
}
pub fn quote(arg: &str) -> String {
let plain = !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "-_./=:@%+,".contains(c));
if plain { arg.to_owned() } else { format!("'{}'", arg.replace('\'', "'\\''")) }
}
+975
View File
@@ -0,0 +1,975 @@
//! Run points inside a file (the editor's gutter ▶ icons) and the run configuration of
//! one: a port of the frontend's `features/run/targets.ts` and `context.ts` for Rust,
//! JavaScript/TypeScript, Python and Go. JVM files are not covered (the editor scans
//! them with a parser of its own).
use ide_run::RunConfig;
use regex_lite::Regex;
use std::path::Path;
use std::sync::LazyLock;
/// Above this size files are not scanned (generated / minified files).
const MAX_SCAN: usize = 400_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Main,
Test,
Suite,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Target {
/// 0-based line of the declaration.
pub line: usize,
pub kind: Kind,
/// Display name (`adds_numbers`, `math > adds`).
pub name: String,
/// Rust module path, describe chain, Python class chain…
pub path: Vec<String>,
}
macro_rules! re {
($name:ident, $re:expr) => {
static $name: LazyLock<Regex> = LazyLock::new(|| Regex::new($re).expect("valid regex"));
};
}
fn indent_of(s: &str) -> usize {
s.chars().take_while(|c| c.is_whitespace()).map(|c| if c == '\t' { 4 } else { 1 }).sum()
}
// ---------------------------------------------------------------- Rust
re!(LINE_COMMENT, r"//.*$");
re!(STRING, r#""(?:[^"\\]|\\.)*""#);
re!(CHAR, r"'(?:[^'\\]|\\.)'");
/// Strips string literals and line comments (rough) so braces inside them don't count.
fn code_only(line: &str) -> String {
let line = LINE_COMMENT.replace(line, "");
let line = STRING.replace_all(&line, "\"\"");
CHAR.replace_all(&line, "''").into_owned()
}
re!(RUST_SRC, r"(?:^|/)src/(.+)\.rs$");
fn rust_module_prefix(rel: &str) -> Vec<String> {
let Some(m) = RUST_SRC.captures(rel) else { return Vec::new() };
let mut parts: Vec<String> = m[1].split('/').map(str::to_owned).collect();
let last = parts.last().cloned().unwrap_or_default();
if parts.len() == 1 && (last == "lib" || last == "main") || parts[0] == "bin" {
return Vec::new();
}
if last == "mod" {
parts.pop();
}
parts
}
re!(RUST_TEST_ATTR, r"^#\[(?:[\w:]+::)?test\b|^#\[(?:tokio|async_std|actix_rt|rstest|test_case)(?:::\w+)?\b");
re!(RUST_CFG_TEST, r"^#\[cfg\(test\)\]");
re!(RUST_FN, r"^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+(\w+)");
re!(RUST_MOD, r"^(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*\{");
re!(RUST_MAIN_FILE, r"(?:^|/)(?:src/main|src/bin/[^/]+|examples/[^/]+)\.rs$");
fn rust_targets(lines: &[&str], rel: &str) -> Vec<Target> {
let mut out: Vec<Target> = Vec::new();
let prefix = rust_module_prefix(rel);
let mut mods: Vec<(String, i64)> = Vec::new();
let mut depth: i64 = 0;
let mut pending_test = false;
let mut pending_cfg_test = false;
for (i, raw) in lines.iter().enumerate() {
let line = code_only(raw);
let trimmed = line.trim();
if RUST_TEST_ATTR.is_match(trimmed) {
pending_test = true;
}
if RUST_CFG_TEST.is_match(trimmed) {
pending_cfg_test = true;
}
let mod_path = || prefix.iter().cloned().chain(mods.iter().map(|m| m.0.clone())).collect::<Vec<_>>();
if let Some(f) = RUST_FN.captures(trimmed) {
let name = f[1].to_owned();
if pending_test {
let mut path = mod_path();
path.push(name.clone());
out.push(Target { line: i, kind: Kind::Test, name, path });
} else if name == "main" && depth == 0 && RUST_MAIN_FILE.is_match(rel) {
out.push(Target { line: i, kind: Kind::Main, name, path: Vec::new() });
}
pending_test = false;
pending_cfg_test = false;
} else if let Some(m) = RUST_MOD.captures(trimmed) {
let name = m[1].to_owned();
let mut path = mod_path();
path.push(name.clone());
if pending_cfg_test || name == "tests" {
out.push(Target { line: i, kind: Kind::Suite, name: name.clone(), path });
}
mods.push((name, depth));
pending_test = false;
pending_cfg_test = false;
} else if !trimmed.is_empty() && !trimmed.starts_with("#[") && !trimmed.starts_with("//") {
pending_test = false;
if !trimmed.starts_with('#') {
pending_cfg_test = false;
}
}
for ch in line.chars() {
if ch == '{' {
depth += 1;
} else if ch == '}' {
depth -= 1;
while mods.last().is_some_and(|m| m.1 >= depth) {
mods.pop();
}
}
}
}
// A suite that contains no tests is noise.
let tests: Vec<Vec<String>> = out.iter().filter(|t| t.kind == Kind::Test).map(|t| t.path.clone()).collect();
out.retain(|t| t.kind != Kind::Suite || tests.iter().any(|p| p.len() > t.path.len() && p.starts_with(&t.path)));
out
}
// ---------------------------------------------------------------- JavaScript / TypeScript
re!(
JS_CALL,
r"^(\s*)(describe|suite|context|test|it)\b(?:\.(?:only|skip|todo|concurrent|failing|sequential))*(\.each\s*(?:\(.*?\)|`[^`]*`))?\s*\(\s*"
);
/// The string literal at the start of `s` (quote, then text up to the same unescaped quote).
fn js_string(s: &str) -> Option<(char, String)> {
let mut chars = s.chars();
let quote = chars.next().filter(|q| matches!(q, '\'' | '"' | '`'))?;
let mut raw = String::new();
while let Some(c) = chars.next() {
if c == quote {
return Some((quote, raw));
}
raw.push(c);
if c == '\\' {
raw.push(chars.next()?);
}
}
None
}
re!(JS_ESCAPE, r"\\(.)");
fn js_targets(lines: &[&str]) -> Vec<Target> {
let mut out = Vec::new();
let mut stack: Vec<(usize, String)> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let Some(m) = JS_CALL.captures(line) else { continue };
let Some((_quote, raw)) = js_string(&line[m.get(0).map_or(0, |g| g.end())..]) else { continue };
let indent = indent_of(&m[1]);
while stack.last().is_some_and(|s| s.0 >= indent) {
stack.pop();
}
let name = JS_ESCAPE.replace_all(&raw, "$1").into_owned();
let mut path: Vec<String> = stack.iter().map(|s| s.1.clone()).collect();
path.push(name.clone());
let suite = matches!(&m[2], "describe" | "suite" | "context");
out.push(Target { line: i, kind: if suite { Kind::Suite } else { Kind::Test }, name: path.join(" > "), path });
if suite {
stack.push((indent, name));
}
}
out
}
fn escape_regexp(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if ".*+?^${}()|[]\\".contains(c) {
out.push('\\');
}
out.push(c);
}
out
}
re!(JS_PATTERN_PLACEHOLDER, r"%[sdifjo#p]|\\\$\\\{[^}]*\\\}|\$\w+");
/// Jest / Vitest `-t` pattern for a test (full name = describe chain + name, space separated).
fn js_name_pattern(target: &Target) -> String {
let pattern = escape_regexp(&target.path.join(" "));
// `test.each` placeholders (%s, $name, ${…}) match anything.
let pattern = JS_PATTERN_PLACEHOLDER.replace_all(&pattern, ".*");
if target.kind == Kind::Suite { format!("^{pattern}") } else { format!("^{pattern}$") }
}
// ---------------------------------------------------------------- Python
re!(PY_COMMENT, r"^\s*#");
re!(PY_CLASS, r"^\s*class\s+(\w+)");
re!(PY_TEST_DEF, r"^\s*(?:async\s+)?def\s+(test\w*)\s*\(");
re!(PY_MAIN, r#"^if\s+__name__\s*==\s*['"]__main__['"]\s*:"#);
fn python_targets(lines: &[&str]) -> Vec<Target> {
let mut out = Vec::new();
let mut classes: Vec<(usize, String, bool)> = Vec::new();
for (i, line) in lines.iter().enumerate() {
if line.trim().is_empty() || PY_COMMENT.is_match(line) {
continue;
}
let indent = indent_of(line);
while classes.last().is_some_and(|c| c.0 >= indent) {
classes.pop();
}
if let Some(c) = PY_CLASS.captures(line) {
let test = c[1].starts_with("Test") && classes.iter().all(|c| c.2);
classes.push((indent, c[1].to_owned(), test));
if test {
let path: Vec<String> = classes.iter().map(|c| c.1.clone()).collect();
out.push(Target { line: i, kind: Kind::Suite, name: c[1].to_owned(), path });
}
continue;
}
if let Some(d) = PY_TEST_DEF.captures(line)
&& classes.iter().all(|c| c.2)
{
let mut path: Vec<String> = classes.iter().map(|c| c.1.clone()).collect();
path.push(d[1].to_owned());
out.push(Target { line: i, kind: Kind::Test, name: path.join("."), path });
continue;
}
if indent == 0 && PY_MAIN.is_match(line) {
out.push(Target { line: i, kind: Kind::Main, name: "main".into(), path: Vec::new() });
}
}
out
}
// ---------------------------------------------------------------- Go
re!(GO_PACKAGE_MAIN, r"^package\s+main\b");
re!(GO_TEST, r"^func\s+(Test\w*|Benchmark\w*|Example\w*|Fuzz\w*)\s*\(\s*(?:\w+\s+\*testing\.[TFB])?\s*\)?");
re!(GO_TESTING_ARG, r"\*testing\.[TFB]");
re!(GO_TESTING_TB, r"\*testing\.[TB]\b");
re!(GO_MAIN, r"^func\s+main\s*\(\s*\)");
re!(GO_ONE_LINE, r"\}\s*(?://.*)?$");
re!(GO_RUN_LITERAL, r#"\b\w+\.Run\(\s*"((?:[^"\\]|\\.)*)"\s*,"#);
re!(GO_RUN_FIELD, r"\b\w+\.Run\(\s*\w+\.(\w+)\s*,");
re!(GO_RUN_KEY, r"\b\w+\.Run\(\s*(\w+)\s*,");
re!(GO_MAP_KEY, r#"^\s*"((?:[^"\\]|\\.)*)"\s*:\s*\{"#);
re!(WHITESPACE, r"\s");
fn go_targets(lines: &[&str]) -> Vec<Target> {
let mut out = Vec::new();
let is_main = lines.iter().any(|l| GO_PACKAGE_MAIN.is_match(l));
for (i, line) in lines.iter().enumerate() {
if let Some(t) = GO_TEST.captures(line) {
let name = t[1].to_owned();
if name.starts_with("Example") || GO_TESTING_ARG.is_match(line) {
out.push(Target { line: i, kind: Kind::Test, name: name.clone(), path: vec![name.clone()] });
if GO_TESTING_TB.is_match(line) {
out.extend(go_subtests(lines, i, &name));
}
continue;
}
}
if is_main && GO_MAIN.is_match(line) {
out.push(Target { line: i, kind: Kind::Main, name: "main".into(), path: Vec::new() });
}
}
out
}
/// Subtests of the test function at `start`: `t.Run("name", …)`, and table entries run with
/// `t.Run(tt.name, …)` (`{name: "…", …}`) or keyed by name (`"…": {…}`).
fn go_subtests(lines: &[&str], start: usize, test: &str) -> Vec<Target> {
if GO_ONE_LINE.is_match(lines[start]) {
return Vec::new();
}
let mut end = start;
while end + 1 < lines.len() && !lines[end].starts_with('}') {
end += 1;
}
let body = &lines[start + 1..end.max(start + 1)];
let mut out: Vec<Target> = Vec::new();
let mut add = |offset: usize, name: &str| {
let sub = WHITESPACE.replace_all(&JS_ESCAPE.replace_all(name, "$1"), "_").into_owned();
if !out.iter().any(|t| t.path[1] == sub) {
out.push(Target {
line: start + 1 + offset,
kind: Kind::Test,
name: format!("{test}/{sub}"),
path: vec![test.to_owned(), sub],
});
}
};
let mut fields: Vec<String> = Vec::new();
let mut keys: Vec<String> = Vec::new();
for (i, l) in body.iter().enumerate() {
if let Some(m) = GO_RUN_LITERAL.captures(l) {
add(i, &m[1]);
}
if let Some(m) = GO_RUN_FIELD.captures(l) {
fields.push(m[1].to_owned());
}
if let Some(m) = GO_RUN_KEY.captures(l) {
let range = Regex::new(&format!(r"\bfor\s+{}\s*,\s*\w+\s*:=\s*range\b", &m[1])).expect("valid regex");
if body[..i].iter().any(|b| range.is_match(b)) {
keys.push(m[1].to_owned());
}
}
}
if !fields.is_empty() || !keys.is_empty() {
let field_res: Vec<Regex> = fields
.iter()
.map(|f| Regex::new(&format!(r#"\b{f}\s*:\s*"((?:[^"\\]|\\.)*)""#)).expect("valid regex"))
.collect();
for (i, l) in body.iter().enumerate() {
for re in &field_res {
if let Some(m) = re.captures(l) {
add(i, &m[1]);
}
}
if !keys.is_empty()
&& let Some(m) = GO_MAP_KEY.captures(l)
{
add(i, &m[1]);
}
}
}
out.sort_by_key(|t| t.line);
out
}
// ---------------------------------------------------------------- detection
re!(JS_FILE, r"\.[cm]?[jt]sx?$");
re!(JS_TEST_FILE, r"\.(test|spec)\.[cm]?[jt]sx?$|(^|/)__tests__/");
re!(PY_TEST_FILE, r"(^|/)(test_[^/]*|[^/]*_test)\.py$|(^|/)tests?/");
/// Run targets of a file (`rel`: project-relative with `/`, picks the language).
pub fn detect(text: &str, rel: &str) -> Vec<Target> {
if text.len() > MAX_SCAN {
return Vec::new();
}
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
if rel.ends_with(".rs") {
rust_targets(&lines, rel)
} else if JS_FILE.is_match(rel) {
if JS_TEST_FILE.is_match(rel) { js_targets(&lines) } else { Vec::new() }
} else if rel.ends_with(".py") {
let test_file = PY_TEST_FILE.is_match(rel);
python_targets(&lines).into_iter().filter(|t| t.kind == Kind::Main || test_file).collect()
} else if rel.ends_with(".go") {
let test_file = rel.ends_with("_test.go");
go_targets(&lines).into_iter().filter(|t| t.kind == Kind::Main || test_file).collect()
} else {
Vec::new()
}
}
/// The last line (0-based) of the block declared at `start`: by indentation in Python, else
/// where the braces opened on (or, for a Rust signature, after) the declaration close again.
fn block_end(lines: &[&str], start: usize, rel: &str) -> usize {
if rel.ends_with(".py") {
let indent = indent_of(lines[start]);
let mut end = start;
for (i, line) in lines.iter().enumerate().skip(start + 1) {
if line.trim().is_empty() || PY_COMMENT.is_match(line) {
continue;
}
if indent_of(line) <= indent {
break;
}
end = i;
}
return end;
}
let rust = rel.ends_with(".rs");
let mut depth: i64 = 0;
let mut opened = false;
for (i, raw) in lines.iter().enumerate().skip(start) {
let line = code_only(raw);
for ch in line.chars() {
match ch {
'{' => {
depth += 1;
opened = true;
}
'}' => depth -= 1,
// A Rust declaration without a body (`fn f();`).
';' if rust && !opened => return i,
_ => {}
}
if opened && depth <= 0 {
return i;
}
}
// Only a Rust signature continues on the next lines; a call without a block ends here.
if !opened && !rust {
return i;
}
}
lines.len().saturating_sub(1)
}
/// The run targets whose declaration or body contains `line` (0-based), innermost first.
pub fn enclosing(text: &str, rel: &str, line: usize) -> Vec<Target> {
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let mut out: Vec<Target> =
detect(text, rel).into_iter().filter(|t| t.line <= line && line <= block_end(&lines, t.line, rel)).collect();
out.sort_by_key(|t| std::cmp::Reverse(t.line));
out
}
// ---------------------------------------------------------------- configurations
/// A configuration's working directory relative to the project root, with `/` (Go: its module).
fn cwd_of(root: &Path, c: &RunConfig) -> String {
let module = (c.kind == "go").then(|| c.extras.params.get("module").cloned()).flatten();
let cwd = module
.or_else(|| c.cwd.as_deref().map(|p| p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/")));
let cwd = cwd.unwrap_or_default();
let cwd = cwd.strip_prefix("./").or_else(|| cwd.strip_prefix('.')).unwrap_or(&cwd);
cwd.trim_end_matches('/').to_owned()
}
/// Configs whose working directory most specifically contains `rel` (ties in their original order).
fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> {
let score = |c: &RunConfig| -> Option<usize> {
let cwd = cwd_of(root, c);
if cwd.is_empty() { Some(0) } else { (rel == cwd || rel.starts_with(&format!("{cwd}/"))).then_some(cwd.len()) }
};
let scored: Vec<(&RunConfig, usize)> = configs.iter().filter_map(|c| score(c).map(|s| (*c, s))).collect();
let best = scored.iter().map(|s| s.1).max();
scored.into_iter().filter(|s| Some(s.1) == best).map(|s| s.0).collect()
}
fn closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Option<&'a RunConfig> {
all_closest(root, configs, rel).into_iter().next()
}
fn rel_to_cwd(root: &Path, rel: &str, c: &RunConfig) -> String {
let cwd = cwd_of(root, c);
match rel.strip_prefix(&format!("{cwd}/")) {
Some(inner) if !cwd.is_empty() => inner.to_owned(),
_ => rel.to_owned(),
}
}
fn file_name(rel: &str) -> &str {
rel.rsplit('/').next().unwrap_or(rel)
}
fn dir_of(p: &str) -> &str {
p.rfind('/').map_or("", |i| &p[..i])
}
fn derived(base: &RunConfig, rel: &str, args: Vec<String>) -> RunConfig {
RunConfig {
id: format!("context:{}:{rel}", base.id),
name: file_name(rel).to_owned(),
args,
detected: true,
..base.clone()
}
}
/// Kinds whose test configs can run a file of this extension.
fn test_kinds(rel: &str) -> &'static [&'static str] {
if JS_FILE.is_match(rel) {
&["npm", "deno", "bun"]
} else if rel.ends_with(".py") {
&["pytest", "python"]
} else if rel.ends_with(".go") {
&["go"]
} else if rel.ends_with(".rs") {
&["cargo"]
} else {
&[]
}
}
/// The project's Python interpreter as `(command, leading args)`, from detected configs.
fn project_python(configs: &[RunConfig]) -> Option<(String, Vec<String>)> {
if let Some(py) = configs.iter().find(|c| c.kind == "python") {
let prefix = match py.args.iter().position(|a| a == "-m") {
Some(m) => py.args[..m].to_vec(),
None => py.args.iter().filter(|a| !a.ends_with(".py")).cloned().collect(),
};
return Some((py.command.clone(), prefix));
}
let pytest = configs.iter().find(|c| c.kind == "pytest")?;
static VENV: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(.*[/\\](?:bin|Scripts)[/\\])pytest(\.exe)?$").expect("valid regex"));
if let Some(m) = VENV.captures(&pytest.command) {
return Some((format!("{}python{}", &m[1], m.get(2).map_or("", |g| g.as_str())), Vec::new()));
}
if let Some(m) = pytest.args.iter().position(|a| a == "-m")
&& pytest.args.get(m + 1).is_some_and(|a| a == "pytest")
{
return Some((pytest.command.clone(), pytest.args[..m].to_vec()));
}
if pytest.args.first().is_some_and(|a| a == "run") && pytest.args.get(1).is_some_and(|a| a == "pytest") {
return Some((pytest.command.clone(), vec!["run".into(), "python".into()]));
}
None
}
fn blank(id: String, name: &str, kind: &str, command: String, args: Vec<String>) -> RunConfig {
RunConfig {
id,
name: name.to_owned(),
kind: kind.to_owned(),
command,
args,
cwd: None,
env: Default::default(),
detected: true,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
}
}
/// PyCharm runs pytest for a test file even when the project declares no test setup.
fn implicit_pytest(rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
if !rel.ends_with(".py") {
return None;
}
let (command, mut args) = project_python(configs).unwrap_or_else(|| ("python3".into(), Vec::new()));
args.extend(["-m".to_owned(), "pytest".to_owned()]);
let mut c = blank("pytest:implicit".into(), "pytest", "pytest", command, args);
c.is_test = true;
c.debug_adapter = Some("debugpy".into());
Some(c)
}
fn test_base_for(root: &Path, rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
let kinds = test_kinds(rel);
// Configurations produced from a test or file are results of narrowing, never bases for it.
let candidates: Vec<&RunConfig> = configs
.iter()
.filter(|c| c.is_test && kinds.contains(&c.kind.as_str()) && !c.id.starts_with("context:"))
.collect();
closest(root, &candidates, rel).or(candidates.first().copied()).cloned().or_else(|| implicit_pytest(rel, configs))
}
/// Go test flags may come before or after package targets; binary arguments start at `-args`.
fn go_test_parts(args: &[String]) -> (Vec<String>, Vec<String>, Vec<String>) {
const BOOLEAN_FLAGS: &[&str] = &[
"-a",
"-n",
"-race",
"-msan",
"-asan",
"-cover",
"-linkshared",
"-modcacherw",
"-trimpath",
"-v",
"-work",
"-x",
"-json",
"-c",
"-i",
"-short",
"-failfast",
"-benchmem",
"-fullpath",
"-buildvcs",
];
let (mut flags, mut targets, mut program) = (Vec::new(), Vec::new(), Vec::new());
let mut i = 1;
while i < args.len() {
let arg = &args[i];
if arg == "-args" || arg == "--" {
program = args[i..].to_vec();
break;
}
if arg.starts_with('-') {
flags.push(arg.clone());
if !arg.contains('=') && !BOOLEAN_FLAGS.contains(&arg.as_str()) && i + 1 < args.len() {
i += 1;
flags.push(args[i].clone());
}
} else {
targets.push(arg.clone());
}
i += 1;
}
(flags, targets, program)
}
fn without_go_selection(flags: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut i = 0;
while i < flags.len() {
let f = flags[i].as_str();
if matches!(f, "-run" | "-skip" | "-bench" | "-fuzz") {
i += 2;
continue;
}
if ["-run=", "-skip=", "-bench=", "-fuzz="].iter().any(|p| f.starts_with(p)) {
i += 1;
continue;
}
out.push(flags[i].clone());
i += 1;
}
out
}
/// File-level test configuration for `rel` (the closest matching test config narrowed to the file).
fn test_config_for(root: &Path, rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
let base = test_base_for(root, rel, configs)?;
let file = rel_to_cwd(root, rel, &base);
let with = |extra: &[String]| [base.args.clone(), extra.to_vec()].concat();
Some(match base.kind.as_str() {
// `npm run test -- file` works for npm, pnpm, yarn and bun.
"npm" | "bun" if base.args.iter().any(|a| a == "--") => derived(&base, rel, with(&[file])),
"npm" | "bun" => derived(&base, rel, with(&["--".into(), file])),
"deno" | "pytest" | "python" => derived(&base, rel, with(&[file])),
"go" => {
let dir = dir_of(&file);
let target = if dir.is_empty() { ".".to_owned() } else { format!("./{dir}") };
let (flags, _, program) = go_test_parts(&base.args);
let args = [vec!["test".to_owned()], flags, vec![target.clone()], program].concat();
let mut c = derived(&base, rel, args);
c.name = format!("go test {target}");
if c.extras.params.contains_key("goKind") {
c.extras.params.insert("scope".into(), "directory".into());
c.extras.params.insert("target".into(), target);
}
c
}
"cargo" => {
static TESTS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?:^|/)tests/([^/]+)\.rs$").expect("valid regex"));
match TESTS.captures(rel) {
Some(m) => {
// Cargo's own options go before the test binary's `--`.
let at = base.args.iter().position(|a| a == "--").unwrap_or(base.args.len());
let mut args = base.args.clone();
args.splice(at..at, ["--test".to_owned(), m[1].to_owned()]);
derived(&base, rel, args)
}
None => base,
}
}
_ => base,
})
}
fn main_config_for(root: &Path, rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
if rel.ends_with(".rs") {
static BIN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"src/bin/([^/]+)\.rs$").expect("valid regex"));
let bin = BIN.captures(rel).map(|m| m[1].to_owned());
let runs: Vec<&RunConfig> =
configs.iter().filter(|c| c.kind == "cargo" && c.id.starts_with("cargo:run")).collect();
let by_bin =
bin.and_then(|b| runs.iter().find(|c| c.id == format!("cargo:run:{b}") || c.args.contains(&b)).copied());
return by_bin.or_else(|| closest(root, &runs, rel)).or(runs.first().copied()).cloned();
}
if rel.ends_with(".go") {
let gos: Vec<&RunConfig> = configs.iter().filter(|c| c.kind == "go").collect();
let runs: Vec<&RunConfig> =
gos.iter().filter(|c| c.args.first().is_some_and(|a| a == "run")).copied().collect();
// The module containing the file (nested modules run from their own directory).
let base = closest(root, &runs, rel)
.or_else(|| closest(root, &gos, rel))
.or(runs.first().copied())
.or(gos.first().copied())?;
let dir = rel_to_cwd(root, rel, base);
let dir = dir_of(&dir);
let target = if dir.is_empty() { ".".to_owned() } else { format!("./{dir}") };
if let Some(hit) = runs.iter().find(|c| c.cwd == base.cwd && c.args.get(1) == Some(&target)) {
return Some((*hit).clone());
}
let mut c = derived(base, rel, vec!["run".into(), target.clone()]);
c.name = format!("go run {target}");
c.is_test = false;
return Some(c);
}
None
}
fn script_config_for(rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
let id = format!("context:file:{rel}");
let name = file_name(rel);
let script = |command: &str, args: Vec<String>| blank(id.clone(), name, "shell", command.to_owned(), args);
if rel.ends_with(".py") {
// Reuse the project's interpreter (venv / uv / poetry) when detected.
let (command, mut args) = project_python(configs).unwrap_or_else(|| ("python3".into(), Vec::new()));
args.push(rel.to_owned());
let mut c = blank(id, name, "python", command, args);
c.debug_adapter = Some("debugpy".into());
return Some(c);
}
static JS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\.[cm]?js$").expect("valid regex"));
if JS.is_match(rel) {
return Some(script("node", vec![rel.to_owned()]));
}
if rel.ends_with(".ts") {
return Some(script("npx", vec!["tsx".into(), rel.to_owned()]));
}
for (ext, shell) in [(".zsh", "zsh"), (".bash", "bash"), (".sh", "sh")] {
if rel.ends_with(ext) {
return Some(script(shell, vec![rel.to_owned()]));
}
}
None
}
re!(TEST_FILE, r"\.(test|spec)\.[cm]?[jt]sx?$|(^|/)__tests__/|(^|/)(test_[^/]*\.py|[^/]*_test\.py|[^/]*_test\.go)$");
re!(RUST_TESTS_FILE, r"(^|/)tests/[^/]+\.rs$");
re!(RUST_MAIN_ROLE, r"(^|/)src/main\.rs$|(^|/)src/bin/[^/]+\.rs$");
re!(GO_PACKAGE_MAIN_MULTILINE, r"(?m)^\s*package\s+main\b");
re!(SCRIPT_FILE, r"\.(py|sh|bash|zsh|[cm]?js|ts)$");
/// "Run Context Configuration" for the whole file: its tests, its main target or the script.
pub fn context_config(root: &Path, rel: &str, configs: &[RunConfig], content: &str) -> Option<RunConfig> {
let name = file_name(rel);
if TEST_FILE.is_match(rel) || (RUST_TESTS_FILE.is_match(rel) && name != "mod.rs") {
return test_config_for(root, rel, configs);
}
if RUST_MAIN_ROLE.is_match(rel) || (rel.ends_with(".go") && GO_PACKAGE_MAIN_MULTILINE.is_match(content)) {
return main_config_for(root, rel, configs).or_else(|| script_config_for(rel, configs));
}
if SCRIPT_FILE.is_match(name) {
return script_config_for(rel, configs);
}
None
}
fn replace_last(mut args: Vec<String>, f: impl FnOnce(&str) -> String) -> Vec<String> {
if let Some(last) = args.last_mut() {
*last = f(last);
}
args
}
/// The run configuration of one target, or `None` when the project has no matching runner.
pub fn target_config(
root: &Path,
target: &Target,
rel: &str,
configs: &[RunConfig],
content: &str,
) -> Option<RunConfig> {
if target.kind == Kind::Main {
return context_config(root, rel, configs, content);
}
let file = test_config_for(root, rel, configs)?;
let short =
if target.kind == Kind::Suite { target.path.last().cloned().unwrap_or_default() } else { target.name.clone() };
let named = |args: Vec<String>| RunConfig {
id: format!("context:target:{}:{rel}:{}", file.id, target.path.join("/")),
name: short.clone(),
args,
detected: true,
..file.clone()
};
let with = |extra: Vec<String>| [file.args.clone(), extra].concat();
match file.kind.as_str() {
"npm" | "bun" | "deno" => Some(named(with(vec!["-t".into(), js_name_pattern(target)]))),
"pytest" | "python" => {
Some(named(replace_last(file.args.clone(), |f| format!("{f}::{}", target.path.join("::")))))
}
"go" => {
let pattern = format!("^{}$", target.path.iter().map(|p| escape_regexp(p)).collect::<Vec<_>>().join("$/^"));
let benchmark = target.path.first().is_some_and(|p| p.starts_with("Benchmark"));
let (flags, targets, program) = go_test_parts(&file.args);
let selection: Vec<String> = if benchmark {
vec!["-run".into(), "^$".into(), "-bench".into(), pattern.clone()]
} else {
vec!["-run".into(), pattern.clone()]
};
let flags = without_go_selection(&flags);
let mut c = named([vec!["test".to_owned()], flags.clone(), targets, selection, program].concat());
if c.extras.params.contains_key("goKind") {
let mut tool_args: Vec<String> = flags;
if benchmark {
tool_args.extend(["-bench".to_owned(), pattern.clone()]);
}
c.extras.params.insert("pattern".into(), if benchmark { "^$".into() } else { pattern });
c.extras.params.insert(
"toolArgs".into(),
tool_args.iter().map(|a| super::process::quote(a)).collect::<Vec<_>>().join(" "),
);
}
Some(c)
}
"cargo" => {
let path = target.path.join("::");
// Test-binary arguments follow the one `--` (`cargo test -- --nocapture` has it already).
let separator: Vec<String> =
if file.args.iter().any(|a| a == "--") { Vec::new() } else { vec!["--".into()] };
Some(named(if target.kind == Kind::Suite {
with([separator, vec![format!("{path}::")]].concat())
} else {
with([separator, vec![path, "--exact".into()]].concat())
}))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pick(ts: &[Target]) -> Vec<(usize, Kind, String)> {
ts.iter().map(|t| (t.line, t.kind, t.path.join("/"))).collect()
}
#[test]
fn rust_main_tests_and_test_modules() {
let src = [
"fn main() {}",
"#[cfg(test)]",
"mod tests {",
" use super::*;",
" #[test]",
" fn adds() { let s = \"}\"; }",
" #[tokio::test]",
" #[ignore]",
" async fn async_one() {}",
" fn helper() {}",
"}",
"fn after() {}",
]
.join("\n");
assert_eq!(
pick(&detect(&src, "src/main.rs")),
[
(0, Kind::Main, String::new()),
(2, Kind::Suite, "tests".into()),
(5, Kind::Test, "tests/adds".into()),
(8, Kind::Test, "tests/async_one".into()),
]
);
assert_eq!(
pick(&detect("#[test]\nfn x() {}", "crates/a/src/foo/bar.rs")),
[(1, Kind::Test, "foo/bar/x".into())]
);
assert!(detect("fn main() {}", "src/lib.rs").is_empty());
}
#[test]
fn js_describe_chains() {
let src = [
"describe('math', () => {",
" it('adds', () => {});",
" describe(\"nested\", () => {",
" test.each([[1]])('n=%i', () => {});",
" });",
" test.skip(`subs`, () => {});",
"});",
"const item = items.find((x) => x);",
"test('top', () => {});",
]
.join("\n");
let ts = detect(&src, "src/math.test.ts");
assert_eq!(
pick(&ts),
[
(0, Kind::Suite, "math".into()),
(1, Kind::Test, "math/adds".into()),
(2, Kind::Suite, "math/nested".into()),
(3, Kind::Test, "math/nested/n=%i".into()),
(5, Kind::Test, "math/subs".into()),
(8, Kind::Test, "top".into()),
]
);
assert_eq!(js_name_pattern(&ts[1]), "^math adds$");
assert_eq!(js_name_pattern(&ts[3]), "^math nested n=.*$");
assert_eq!(js_name_pattern(&ts[0]), "^math");
assert!(detect("test('x', () => {})", "src/math.ts").is_empty());
}
#[test]
fn python_and_go() {
let src = "class TestCalc:\n def test_add(self):\n pass\ndef test_top():\n pass\nclass Helper:\n def test_no(self):\n pass\nif __name__ == '__main__':\n main()\n";
assert_eq!(
pick(&detect(src, "tests/test_calc.py")),
[
(0, Kind::Suite, "TestCalc".into()),
(1, Kind::Test, "TestCalc/test_add".into()),
(3, Kind::Test, "test_top".into()),
(8, Kind::Main, String::new()),
]
);
let go = "package calc\n\nfunc TestAdd(t *testing.T) {\n\tt.Run(\"zero case\", func(t *testing.T) {})\n}\n";
assert_eq!(
pick(&detect(go, "calc_test.go")),
[(2, Kind::Test, "TestAdd".into()), (3, Kind::Test, "TestAdd/zero_case".into())]
);
}
#[test]
fn narrows_cargo_test_configs() {
let root = Path::new("/p");
let mut test = blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into()]);
test.is_test = true;
let configs = vec![test];
let src = "#[cfg(test)]\nmod tests {\n #[test]\n fn adds() {}\n}\n";
let ts = detect(src, "src/math.rs");
let c = target_config(root, &ts[1], "src/math.rs", &configs, src).unwrap();
assert_eq!(c.args, ["test", "--", "math::tests::adds", "--exact"]);
let c = target_config(root, &ts[0], "src/math.rs", &configs, src).unwrap();
assert_eq!(c.args, ["test", "--", "math::tests::"]);
}
#[test]
fn cargo_test_args_get_one_separator() {
let root = Path::new("/p");
let mut test =
blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into(), "--".into(), "-q".into()]);
test.is_test = true;
let configs = vec![test];
let src = "#[test]\nfn adds() {}\n";
let ts = detect(src, "tests/math.rs");
let c = target_config(root, &ts[0], "tests/math.rs", &configs, src).unwrap();
assert_eq!(c.args, ["test", "--test", "math", "--", "-q", "adds", "--exact"]);
}
#[test]
fn a_line_in_a_body_picks_the_enclosing_target() {
let names = |src: &str, rel: &str, line: usize| -> Vec<String> {
enclosing(src, rel, line).iter().map(|t| t.path.join("/")).collect()
};
let rust = [
"#[cfg(test)]",
"mod tests {",
" #[test]",
" fn adds(",
" x: u8,",
" ) {",
" let s = \"}\";",
" assert!(true);",
" }",
"",
" fn helper() {}",
"}",
]
.join("\n");
assert_eq!(names(&rust, "src/math.rs", 7), ["math/tests/adds", "math/tests"]);
assert_eq!(names(&rust, "src/math.rs", 4), ["math/tests/adds", "math/tests"]);
assert_eq!(names(&rust, "src/math.rs", 10), ["math/tests"]);
let js = "describe('math', () => {\n it('adds', () => {\n expect(1).toBe(1);\n });\n it('one', fn);\n const x = 1;\n});\n";
assert_eq!(names(js, "src/math.test.ts", 2), ["math/adds", "math"]);
assert_eq!(names(js, "src/math.test.ts", 5), ["math"]);
let py = "class TestCalc:\n def test_add(self):\n x = 1\n\n assert x\n def helper(self):\n pass\n";
assert_eq!(names(py, "tests/test_calc.py", 4), ["TestCalc/test_add", "TestCalc"]);
assert_eq!(names(py, "tests/test_calc.py", 6), ["TestCalc"]);
let go = "package calc\n\nfunc TestAdd(t *testing.T) {\n\tt.Run(\"zero\", func(t *testing.T) {\n\t\tx := 0\n\t})\n\ty := 1\n}\n";
assert_eq!(names(go, "calc_test.go", 4), ["TestAdd/zero", "TestAdd"]);
assert_eq!(names(go, "calc_test.go", 6), ["TestAdd"]);
assert!(names(go, "calc_test.go", 0).is_empty());
}
}
+901
View File
@@ -0,0 +1,901 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/SearchToolset.kt,
// SearchSymbolSupport.kt), Apache-2.0.
//! Text, regex, file and symbol search.
//!
//! Coordinates are 1-based, end exclusive; columns count UTF-16 code units like
//! the editor (one per character except emoji and other astral characters).
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::mcp::util::{INDEXING_PARTIAL_RESULT_REASON, MEDIUM_TIMEOUT};
use crate::ops::search::SearchEvent;
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use ide_core::{CancellationToken, IdeError, IdeResult, Location, Range};
use ide_search::{FileMask, FileMatches, SearchQuery, SearchScope, SearchSummary};
use ide_vfs::EntryKind;
use parking_lot::Mutex;
use serde::Serialize;
use serde_json::{Value, json};
use std::cell::Cell;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
pub const MAX_RESULTS_UPPER_BOUND: usize = 5000;
const DEFAULT_LIMIT: i64 = 1000;
pub const SYMBOL_SEARCH_DEFAULT_LIMIT: i64 = 50;
pub const SEARCH_TIMEOUT_PARTIAL_RESULT_REASON: &str =
"The search timed out after 10 s. Narrow the query or pass paths.";
/// Symbols requested from the index per result when `paths` filters them afterwards.
const SYMBOL_POOL_FACTOR: usize = 20;
const MAX_SYMBOL_POOL: usize = 20_000;
/// Matches Find in Files may report, per result asked for, when `paths` are filtered afterwards.
const UNMASKED_MATCH_FACTOR: usize = 20;
const MIN_UNMASKED_MATCHES: usize = 10_000;
/// UTF-16 units of a line Find in Files reports (`ide_search`'s line window); matches
/// outside the window of a longer line are left out.
const LINE_WINDOW: usize = 500;
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
pub fn tools() -> Vec<Tool> {
vec![search_text(), search_regex(), search_symbol(), search_file()]
}
fn search_text() -> Tool {
Tool::new(
"search_text",
"Searches for a text substring within project files.
Use this tool for fast text search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
Paths are glob patterns relative to the project root.
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|call| async move { text_search(call, false).await },
)
.param(Param::required("q", ParamType::String, "Text to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.output(result_schema())
}
fn search_regex() -> Tool {
Tool::new(
"search_regex",
"Searches for regex matches within project files.
Use this tool when you need regex search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case.
Paths are glob patterns relative to the project root.
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|call| async move { text_search(call, true).await },
)
.param(Param::required("q", ParamType::String, "Regex pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.output(result_schema())
}
fn search_symbol() -> Tool {
Tool::new(
"search_symbol",
"Searches for symbols (classes, methods, fields).
Use this tool for semantic lookup by identifier fragments.
Results include match coordinates when available (1-based line/column, end exclusive).
Paths are glob patterns relative to the project root.
By default this searches project symbols only.
If you don't find a suitable result, try again with include_external=true to search SDK and library symbols too.",
|call| async move { symbol_search(call).await },
)
.param(Param::required("q", ParamType::String, "Symbol query text"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional(
"include_external",
ParamType::Boolean,
"Whether to include SDK and library symbols. Disabled by default; if nothing suitable is found, try again \
with include_external=true.",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.output(result_schema())
}
fn search_file() -> Tool {
Tool::new(
"search_file",
"Searches for files by glob pattern within the project.
Use this tool when you need to match file paths using glob syntax.
Glob patterns are relative to the project root.
Examples: \"**/*.kt\", \"src/**/Foo*.java\", \"build.gradle.kts\".
Patterns without '/' are treated as \"**/pattern\".
Paths are optional additional glob filters relative to the project root.",
|call| async move { file_search(call).await },
)
.param(Param::required("q", ParamType::String, "Glob pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional(
"includeExcluded",
ParamType::Boolean,
"Whether to include excluded/ignored files in results",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.output(result_schema())
}
// --- results -------------------------------------------------------------------
/// One hit. Every search tool returns these, so they share one schema: `search_file`
/// fills only `filePath`, only `search_symbol` fills `name` and `container`.
#[derive(Serialize, Debug, Default, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
struct SearchItem {
file_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
start_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
start_column: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
end_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
end_column: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
container: Option<String>,
}
impl SearchItem {
fn at(file_path: String, r: Range) -> Self {
Self {
file_path,
start_line: Some(r.start.line + 1),
start_column: Some(r.start.col + 1),
end_line: Some(r.end.line + 1),
end_column: Some(r.end.col + 1),
..Default::default()
}
}
}
/// Empty fields are left out, so a search without hits is `{}`.
#[derive(Serialize, Default)]
#[serde(rename_all = "camelCase")]
struct SearchResult {
#[serde(skip_serializing_if = "Vec::is_empty")]
items: Vec<SearchItem>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
more: bool,
#[serde(skip_serializing_if = "Option::is_none")]
partial_result_reason: Option<&'static str>,
}
fn result_schema() -> Value {
let int = json!({ "type": "integer" });
let string = json!({ "type": "string" });
json!({
"type": "object",
"properties": {
"items": { "type": "array", "items": {
"type": "object",
"properties": {
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
"name": string, "container": string,
},
"required": ["filePath"],
} },
"more": { "type": "boolean" },
"partialResultReason": string,
},
})
}
fn limit(call: &Call, default: i64) -> IdeResult<usize> {
match call.opt_i64("limit")?.unwrap_or(default) {
n if n <= 0 => Err(IdeError::invalid("limit must be > 0")),
n => Ok((n as usize).min(MAX_RESULTS_UPPER_BOUND)),
}
}
fn query(call: &Call, empty: &str) -> IdeResult<String> {
let q = call.str("q")?;
if q.trim().is_empty() {
return Err(IdeError::invalid(empty));
}
Ok(q)
}
// --- path globs ----------------------------------------------------------------
/// The `paths` argument: project-relative globs, `!` excludes.
struct PathScope {
/// `None` when only excludes were given (everything is included).
include: Option<GlobSet>,
exclude: GlobSet,
/// The globs as `FileMask` masks for Find in Files (`{a,b}` expanded, as masks
/// split on commas), `None` when they cannot be expressed.
masks: Option<(Vec<String>, Vec<String>)>,
}
impl PathScope {
fn new(root: &Path, patterns: &[String]) -> IdeResult<Self> {
let (mut include, mut exclude) = (GlobSetBuilder::new(), GlobSetBuilder::new());
let (mut include_masks, mut exclude_masks) = (Vec::new(), Vec::new());
let mut has_include = false;
let mut maskable = true;
for raw in patterns {
let raw = raw.trim();
if raw.is_empty() {
continue;
}
let (negated, pattern) = match raw.strip_prefix('!') {
Some(rest) if rest.trim().is_empty() => return Err(IdeError::invalid("Exclude pattern is empty")),
Some(rest) => (true, rest.trim()),
None => (false, raw),
};
let mut glob = normalize_glob(pattern, root)?;
// A path naming an existing directory covers the files below it.
if index_of_glob_char(&glob).is_none() && root.join(&glob).is_dir() {
glob.push_str("/**");
}
let alternatives = expand_braces(&glob);
maskable &= alternatives.is_some();
let (set, masks) =
if negated { (&mut exclude, &mut exclude_masks) } else { (&mut include, &mut include_masks) };
set.add(build_glob(&glob)?);
masks.extend(alternatives.into_iter().flatten());
has_include |= !negated;
}
let build = |b: GlobSetBuilder| b.build().map_err(|e| IdeError::invalid(format!("Invalid glob pattern: {e}")));
Ok(Self {
include: if has_include { Some(build(include)?) } else { None },
exclude: build(exclude)?,
masks: maskable.then_some((include_masks, exclude_masks)),
})
}
fn is_all(&self) -> bool {
self.include.is_none() && self.exclude.is_empty()
}
/// `path` is project-relative with `/` (absolute outside the project).
fn matches(&self, path: &str) -> bool {
self.include.as_ref().is_none_or(|s| s.is_match(path)) && !self.exclude.is_match(path)
}
}
/// A glob as a project-relative pattern: `\` → `/`, leading `./` dropped, `dir/` → `dir/**`,
/// `name` → `**/name`, and its leading directories (`..`, absolute paths) resolved against
/// `root`. Errors when that points outside the project.
fn normalize_glob(raw: &str, root: &Path) -> IdeResult<String> {
let mut value = raw.trim().replace('\\', "/");
if value.is_empty() {
return Err(IdeError::invalid("Glob pattern is empty"));
}
while let Some(rest) = value.strip_prefix("./") {
value = rest.to_owned();
}
if value.ends_with('/') {
let dir = value.trim_end_matches('/');
value = if dir.is_empty() { "**".into() } else { format!("{dir}/**") };
}
if !value.contains('/') {
value = format!("**/{value}");
}
let value = normalize_path_pattern(&value, root)?;
if value.is_empty() {
return Err(IdeError::invalid(format!("Invalid glob pattern: {raw}")));
}
Ok(value)
}
/// Resolves the whole directory segments before the first glob character against `root`.
/// (JetBrains resolves up to the glob character itself, turning `src/Foo*.java` into
/// `src/Foo/*.java`; only whole segments are resolved here.)
fn normalize_path_pattern(pattern: &str, root: &Path) -> IdeResult<String> {
let outside = || {
IdeError::invalid(format!("Specified path '{pattern}' points to the location outside of the project directory"))
};
let prefix_len = match index_of_glob_char(pattern) {
None => pattern.len(),
Some(i) => pattern[..i].rfind('/').map_or(0, |slash| slash + 1),
};
let prefix = pattern[..prefix_len].trim_end_matches('/');
if prefix.is_empty() {
if is_absolute_pattern(pattern) {
return Err(outside());
}
return Ok(pattern.to_owned());
}
let prefix_path = Path::new(prefix);
let absolute = crate::mcp::tool::normalize(&if prefix_path.is_absolute() {
prefix_path.to_path_buf()
} else {
root.join(prefix_path)
});
let relative = relative_to_root(root, &absolute).ok_or_else(outside)?;
let suffix = pattern[prefix_len..].trim_start_matches('/');
Ok(match (relative.is_empty(), suffix.is_empty()) {
(true, _) => suffix.to_owned(),
(false, true) => relative,
(false, false) => format!("{relative}/{suffix}"),
})
}
/// `path` relative to `root` with `/`, also when one of them is spelled through a symlink
/// (`/var` and `/private/var`); `None` outside `root`.
fn relative_to_root(root: &Path, path: &Path) -> Option<String> {
let rel = match path.strip_prefix(root) {
Ok(rel) => rel.to_path_buf(),
Err(_) => {
let canon_root = dunce::canonicalize(root).ok()?;
let existing = path.ancestors().find(|a| a.exists())?;
let real = dunce::canonicalize(existing).ok()?.join(path.strip_prefix(existing).ok()?);
real.strip_prefix(&canon_root).ok()?.to_path_buf()
}
};
Some(rel.to_string_lossy().replace('\\', "/"))
}
fn is_absolute_pattern(pattern: &str) -> bool {
let b = pattern.as_bytes();
pattern.starts_with('/') || (b.len() >= 3 && b[1] == b':' && b[2] == b'/')
}
fn index_of_glob_char(pattern: &str) -> Option<usize> {
pattern.find(['*', '?', '[', ']', '{', '}'])
}
/// At most this many masks per glob from expanding its `{a,b}` groups.
const MAX_BRACE_ALTERNATIVES: usize = 64;
/// `glob` with its `{a,b}` groups expanded into separate globs without commas, for
/// `FileMask` (which splits on them). `None` for nested groups, braces or commas in a
/// character class, a comma outside a group, an unbalanced brace, or too many results.
fn expand_braces(glob: &str) -> Option<Vec<String>> {
let mut out = vec![String::new()];
let mut chars = glob.chars();
let mut in_class = false;
while let Some(c) = chars.next() {
match c {
'{' | '}' | ',' if in_class => return None,
'[' => {
in_class = true;
out.iter_mut().for_each(|o| o.push(c));
}
']' => {
in_class = false;
out.iter_mut().for_each(|o| o.push(c));
}
'{' => {
let mut group = String::new();
loop {
match chars.next()? {
'}' => break,
'{' | '[' | ']' => return None,
g => group.push(g),
}
}
let options: Vec<&str> = group.split(',').collect();
if out.len() * options.len() > MAX_BRACE_ALTERNATIVES {
return None;
}
out = out.iter().flat_map(|o| options.iter().map(move |a| format!("{o}{a}"))).collect();
}
'}' | ',' => return None,
_ => out.iter_mut().for_each(|o| o.push(c)),
}
}
(!in_class).then_some(out)
}
/// `**/` matches zero or more directories; case follows the file system (insensitive on
/// macOS and Windows, like Find in Files' masks).
fn build_glob(glob: &str) -> IdeResult<globset::Glob> {
GlobBuilder::new(glob)
.literal_separator(true)
.backslash_escape(false)
.case_insensitive(cfg!(any(target_os = "macos", target_os = "windows")))
.build()
.map_err(|_| IdeError::invalid(format!("Invalid glob pattern: {glob}")))
}
// --- text / regex --------------------------------------------------------------
/// A match before it becomes a [`SearchItem`]; 1-based, end exclusive.
struct TextHit {
file_path: String,
line: u32,
column: u32,
end_line: u32,
end_column: u32,
}
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
// Globs Find in Files cannot take are applied afterwards, so the engine must not
// stop at `limit` matches the filter may drop.
let (masks, max_matches) = match scope.masks.clone() {
// One more than asked tells whether there are more.
Some(masks) => (masks, (limit + 1) as u32),
// Bounded all the same (as the symbol pool): past it the result reports `more`.
None => {
let budget = ((limit + 1) * UNMASKED_MATCH_FACTOR).max(MIN_UNMASKED_MATCHES);
(Default::default(), u32::try_from(budget).unwrap_or(u32::MAX))
}
};
let (include, exclude) = masks;
let query = SearchQuery {
pattern: pattern.clone(),
is_regex,
case_sensitive: true,
whole_word: false,
include,
exclude,
scope: SearchScope::Project,
max_matches,
context: Default::default(),
};
if is_regex && let Err(e) = ide_search::match_ranges("", &query) {
return Err(IdeError::invalid(format!("Invalid regex pattern: {pattern} ({})", e.message)));
}
let (files, summary, timed_out) = run_search(&call, query.clone()).await?;
// Content roots outside the project are reported by absolute path.
let mut files: Vec<(String, FileMatches)> =
files.into_iter().map(|f| (call.display(&f.path.0), f)).filter(|(path, _)| scope.matches(path)).collect();
files.sort_by(|a, b| a.0.cmp(&b.0));
// Only these can match a line break.
let may_span = is_regex || pattern.contains('\n');
let mut hits = Vec::new();
let mut spanning = Vec::new();
let host = call.host();
for (path, file) in &files {
if hits.len() > limit {
break;
}
// Find in Files keeps a window of very long lines and drops the matches outside it:
// such lines are matched again in the file's whole text.
let long = |m: &ide_search::LineMatch| m.text_offset > 0 || m.text.encode_utf16().count() + 1 >= LINE_WINDOW;
let whole = if file.matches.iter().any(long) {
crate::ops::fs::read_text(&host, call.window(), file.path.0.clone())
.await
.ok()
.and_then(|text| Some((ide_search::match_ranges(&text, &query).ok()?, text)))
} else {
None
};
for m in &file.matches {
if let Some((ranges, text)) = &whole
&& long(m)
{
hits.extend(line_hits(path, text, ranges, m.line));
continue;
}
let line_end = m.text_offset + m.text.encode_utf16().count() as u32;
for &(a, b) in &m.ranges {
let (a, b) = (m.text_offset + a, m.text_offset + b);
if may_span && b == line_end && hits.len() < limit {
spanning.push((hits.len(), file.path.0.clone()));
}
hits.push(TextHit {
file_path: path.clone(),
line: m.line + 1,
column: a + 1,
end_line: m.line + 1,
end_column: b + 1,
});
}
}
}
let more = hits.len() > limit || summary.limit_hit || timed_out;
hits.truncate(limit);
extend_spanning(&call, &query, &mut hits, spanning).await;
let items = hits
.into_iter()
.map(|h| SearchItem {
file_path: h.file_path,
start_line: Some(h.line),
start_column: Some(h.column),
end_line: Some(h.end_line),
end_column: Some(h.end_column),
..Default::default()
})
.collect();
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, partial_result_reason })
}
/// The matches of `ranges` (byte ranges in `text`) that start on 0-based `line`, with their
/// real end (on a later line for a match spanning lines).
fn line_hits(path: &str, text: &str, ranges: &[(usize, usize)], line: u32) -> Vec<TextHit> {
let starts: Vec<usize> = std::iter::once(0).chain(text.match_indices('\n').map(|(i, _)| i + 1)).collect();
let Some(&line_start) = starts.get(line as usize) else { return Vec::new() };
let line_end = starts.get(line as usize + 1).map_or(text.len(), |s| s - 1);
let column = |at: usize| -> (u32, u32) {
let l = starts.partition_point(|&s| s <= at) - 1;
(l as u32 + 1, text[starts[l]..at].encode_utf16().count() as u32 + 1)
};
let first = ranges.partition_point(|r| r.0 < line_start);
ranges[first..]
.iter()
.take_while(|r| r.0 <= line_end)
.map(|&(a, b)| {
let (end_line, end_column) = column(b);
TextHit { file_path: path.to_owned(), line: line + 1, column: column(a).1, end_line, end_column }
})
.collect()
}
/// A file's path, text, line start offsets and match byte ranges.
type FileText = (PathBuf, String, Vec<usize>, Vec<(usize, usize)>);
/// Find in Files reports only the first line of a match that spans lines: matches
/// ending at a line end get their real end from matching the file's text again.
async fn extend_spanning(call: &Call, query: &SearchQuery, hits: &mut [TextHit], spanning: Vec<(usize, PathBuf)>) {
let host = call.host();
let mut current: Option<FileText> = None;
for (i, path) in spanning {
if current.as_ref().is_none_or(|(p, ..)| p != &path) {
let Ok(text) = crate::ops::fs::read_text(&host, call.window(), path.clone()).await else { continue };
let Ok(ranges) = ide_search::match_ranges(&text, query) else { continue };
let starts = std::iter::once(0).chain(text.match_indices('\n').map(|(i, _)| i + 1)).collect();
current = Some((path, text, starts, ranges));
}
let Some((_, text, starts, ranges)) = &current else { continue };
let h = &mut hits[i];
let Some(&line_start) = starts.get(h.line as usize - 1) else { continue };
let Some(start) = byte_at(&text[line_start..], h.column - 1).map(|b| line_start + b) else { continue };
let Ok(found) = ranges.binary_search_by_key(&start, |r| r.0) else { continue };
let end = ranges[found].1;
let end_line = starts.partition_point(|&s| s <= end) - 1;
h.end_line = end_line as u32 + 1;
h.end_column = text[starts[end_line]..end].encode_utf16().count() as u32 + 1;
}
}
/// The byte offset of UTF-16 column `col` in `line`.
fn byte_at(line: &str, col: u32) -> Option<usize> {
let mut units = 0;
for (i, c) in line.char_indices() {
if units == col {
return Some(i);
}
units += c.len_utf16() as u32;
}
(units == col).then_some(line.len())
}
/// Runs Find in Files for at most [`MEDIUM_TIMEOUT`], cancelling it when the call is
/// cancelled. Returns what was found and whether the time ran out.
async fn run_search(call: &Call, query: SearchQuery) -> IdeResult<(Vec<FileMatches>, SearchSummary, bool)> {
let host = call.host();
let request = crate::mcp::tool::request_id();
let files = Arc::new(Mutex::new(Vec::new()));
let done = Arc::new(Mutex::new(None));
let emit: crate::ops::Emit<SearchEvent> = {
let (files, done) = (files.clone(), done.clone());
Arc::new(move |event| match event {
SearchEvent::File { file } => files.lock().push(file),
SearchEvent::Done { summary } => *done.lock() = Some(summary),
})
};
let search = crate::ops::search::start(&host, call.window(), request, query, emit);
tokio::pin!(search);
let mut timed_out = false;
tokio::select! {
r = &mut search => r?,
_ = call.cancel.cancelled() => {
host.cancel_request(request);
let _ = search.await;
return Err(IdeError::cancelled());
}
_ = tokio::time::sleep(MEDIUM_TIMEOUT) => {
host.cancel_request(request);
let _ = search.await;
timed_out = true;
}
}
let summary = done.lock().take().unwrap_or_default();
if summary.cancelled && !timed_out {
return Err(IdeError::cancelled());
}
let files = std::mem::take(&mut *files.lock());
Ok((files, summary, timed_out))
}
// --- files ---------------------------------------------------------------------
async fn file_search(call: Call) -> IdeResult<Output> {
let q = query(&call, "Glob pattern is empty")?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let include_excluded = call.flag("includeExcluded", false)?;
let glob = GlobSetBuilder::new()
.add(build_glob(&normalize_glob(&q, call.root())?)?)
.build()
.map_err(|_| IdeError::invalid(format!("Invalid glob pattern: {q}")))?;
// Ignored names and "Exclude files" patterns, which `read_dir` does not flag.
let excludes = if include_excluded { None } else { Some(FileMask::new(&[], &call.project().search_excludes())?) };
let root = call.root().to_path_buf();
// The project folder and the content roots outside it.
let roots: Vec<PathBuf> = std::iter::once(root.clone())
.chain(call.project().content_roots().into_iter().filter(|r| relative_to_root(&root, r).is_none()))
.collect();
let host = call.host();
let walk = Walk {
host: host.clone(),
window: call.window().to_owned(),
root,
include_excluded,
excludes,
glob,
scope,
limit,
cancel: call.cancel.clone(),
deadline: Instant::now() + MEDIUM_TIMEOUT,
timed_out: Cell::new(false),
};
let (paths, more, timed_out) = host
.blocking(move || {
let mut found = Vec::new();
let more = roots.iter().any(|base| walk.dir(base, base, &mut found));
if walk.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok((found, more, walk.timed_out.get()))
})
.await?;
let items = paths.into_iter().map(|file_path| SearchItem { file_path, ..Default::default() }).collect();
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, partial_result_reason })
}
struct Walk {
host: Arc<crate::Host>,
window: String,
root: PathBuf,
include_excluded: bool,
excludes: Option<FileMask>,
glob: GlobSet,
scope: PathScope,
limit: usize,
cancel: CancellationToken,
deadline: Instant,
timed_out: Cell<bool>,
}
impl Walk {
/// Collects matching files under `dir` (inside the walked root `base`) depth first in
/// name order; true when it stopped early: more than `limit` match or the time ran out.
fn dir(&self, dir: &Path, base: &Path, found: &mut Vec<String>) -> bool {
let Ok(mut entries) = tokio::runtime::Handle::current().block_on(crate::ops::fs::read_dir(
&self.host,
&self.window,
dir.to_path_buf(),
)) else {
return false;
};
entries.sort_by(|a, b| a.name.cmp(&b.name));
for entry in entries {
if self.cancel.is_cancelled() {
return false;
}
if Instant::now() >= self.deadline {
self.timed_out.set(true);
return true;
}
if entry.name == ".git" || (!self.include_excluded && (entry.ignored || entry.excluded)) {
continue;
}
let Ok(in_base) = entry.path.strip_prefix(base) else { continue };
if self.excludes.as_ref().is_some_and(|m| !m.matches(&in_base.to_string_lossy().replace('\\', "/"))) {
continue;
}
match entry.kind {
// Symlinked directories are not followed (they may loop).
EntryKind::Dir => {
if self.dir(&entry.path, base, found) {
return true;
}
}
EntryKind::File => {
// Files of content roots outside the project go by absolute path.
let path = relative_to_root(&self.root, &entry.path)
.unwrap_or_else(|| entry.path.to_string_lossy().into_owned());
if self.glob.is_match(&path) && self.scope.matches(&path) {
if found.len() == self.limit {
return true;
}
found.push(path);
}
}
_ => {}
}
}
false
}
}
// --- symbols -------------------------------------------------------------------
async fn symbol_search(call: Call) -> IdeResult<Output> {
let started = Instant::now();
let q = query(&call, "Search query is empty")?;
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let external = call.flag("include_external", false)?;
let host = call.host();
let project = call.project().clone();
let indexing_before = project.index.status().indexing;
let pool = if scope.is_all() { limit + 1 } else { ((limit + 1) * SYMBOL_POOL_FACTOR).min(MAX_SYMBOL_POOL) };
let found = {
let (project, q) = (project.clone(), q.clone());
host.blocking(move || Ok(project.index.find_symbols(&q, ide_index::SymbolFilter::All, pool))).await?
};
let mut items: Vec<SearchItem> = found
.into_iter()
.filter(|s| scope.matches(&s.rel_path))
.map(|s| SearchItem {
name: Some(s.name),
container: s.container.filter(|c| !c.is_empty()),
..SearchItem::at(s.rel_path, s.range)
})
.collect();
let mut timed_out = false;
if external && items.len() <= limit {
let budget = MEDIUM_TIMEOUT.saturating_sub(started.elapsed());
let (symbols, out_of_time) = workspace_symbols(&call, &q, budget).await;
timed_out = out_of_time;
for (name, container, location) in symbols {
let path = location.uri.clone().unwrap_or_else(|| call.display(&location.path));
// Project files obey `paths`; library symbols are what was asked for.
let inside = location.uri.is_none() && !Path::new(&path).is_absolute();
if inside && !scope.matches(&path) {
continue;
}
let item = SearchItem {
name: Some(name),
container: container.filter(|c| !c.is_empty()),
..SearchItem::at(path, location.range)
};
if !items
.iter()
.any(|e| e.name == item.name && e.file_path == item.file_path && e.start_line == item.start_line)
{
items.push(item);
}
if items.len() > limit {
break;
}
}
}
let more = items.len() > limit || timed_out;
items.truncate(limit);
let indexing = indexing_before || project.index.status().indexing;
let partial_result_reason = if indexing {
Some(INDEXING_PARTIAL_RESULT_REASON)
} else {
timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON)
};
Output::json(SearchResult { items, more, partial_result_reason })
}
/// The language servers' workspace symbols, and whether `budget` ran out first; empty
/// when none runs or they fail.
async fn workspace_symbols(call: &Call, q: &str, budget: Duration) -> (Vec<(String, Option<String>, Location)>, bool) {
let host = call.host();
let request = crate::mcp::tool::request_id();
let symbols = crate::ops::lsp::lsp_workspace_symbols(&host, call.window(), request, q.to_owned());
tokio::pin!(symbols);
let result = tokio::select! {
r = tokio::time::timeout(budget, &mut symbols) => r.ok(),
_ = call.cancel.cancelled() => None,
};
match result {
Some(Ok(symbols)) => (symbols.into_iter().map(|s| (s.name, s.container, s.location)).collect(), false),
Some(Err(e)) => {
tracing::debug!("MCP search_symbol: workspace symbols: {e}");
(Vec::new(), false)
}
None => {
host.cancel_request(request);
let _ = symbols.await;
(Vec::new(), !call.cancel.is_cancelled())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn root() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap().join("project");
std::fs::create_dir_all(root.join("subdir1/nested")).unwrap();
(dir, root)
}
fn scope(root: &Path, p: &[&str]) -> PathScope {
PathScope::new(root, &p.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap()
}
#[test]
fn normalizes_globs() {
let (_dir, root) = root();
let n = |p: &str| normalize_glob(p, &root);
assert_eq!(n("*.rs").unwrap(), "**/*.rs");
assert_eq!(n("./foo/bar.txt").unwrap(), "foo/bar.txt");
assert_eq!(n("foo/").unwrap(), "foo/**");
assert_eq!(n("/").unwrap(), "**/**");
assert_eq!(n("./src/**/*.ts").unwrap(), "src/**/*.ts");
assert_eq!(n("src\\gen\\*.rs").unwrap(), "src/gen/*.rs");
assert_eq!(n("src/a/../Foo*.java").unwrap(), "src/Foo*.java");
assert_eq!(n(&root.join("sub/Foo.kt").to_string_lossy()).unwrap(), "sub/Foo.kt");
assert_eq!(n(&format!("{}/**/*.kt", root.display())).unwrap(), "**/*.kt");
for outside in ["../outside/**", "/elsewhere/*.rs", "/**"] {
let err = n(outside).unwrap_err().message;
assert!(err.contains("outside of the project directory"), "{outside}: {err}");
}
assert!(n(" ").unwrap_err().message.contains("Glob pattern is empty"));
}
#[test]
fn expands_brace_groups() {
let e = |g: &str| expand_braces(g);
assert_eq!(e("src/**/*.rs"), Some(vec!["src/**/*.rs".to_owned()]));
assert_eq!(e("src/**/*.{ts,tsx}"), Some(vec!["src/**/*.ts".to_owned(), "src/**/*.tsx".to_owned()]));
assert_eq!(e("{a,b}/*.{x,y}"), Some(["a/*.x", "a/*.y", "b/*.x", "b/*.y"].map(String::from).to_vec()));
assert_eq!(e("a{,.bak}"), Some(vec!["a".to_owned(), "a.bak".to_owned()]));
assert_eq!(e("[{]x"), None);
assert_eq!(e("{a,{b,c}}"), None);
assert_eq!(e("a,b"), None);
assert_eq!(e("{a,b"), None);
assert_eq!(e("a}"), None);
assert_eq!(e(&"{a,b}".repeat(7)), None);
assert_eq!(e(&"{a,b}".repeat(6)).map(|v| v.len()), Some(64));
}
#[test]
fn utf16_columns_to_bytes() {
assert_eq!(byte_at("a😀b", 0), Some(0));
assert_eq!(byte_at("a😀b", 3), Some(5));
assert_eq!(byte_at("a😀b", 4), Some(6));
assert_eq!(byte_at("a😀b", 2), None);
assert_eq!(byte_at("ab", 5), None);
}
#[test]
fn path_scopes() {
let (_dir, root) = root();
let root = root.as_path();
assert!(scope(root, &[]).is_all());
assert!(scope(root, &["", " "]).is_all());
let s = scope(root, &["subdir1/**", "!**/*.java"]);
assert!(s.matches("subdir1/foo.txt"));
assert!(!s.matches("subdir1/foo.java"));
assert!(!s.matches("subdir2/foo.txt"));
let s = scope(root, &["subdir1/**", "!subdir1/excluded/**"]);
assert!(s.matches("subdir1/foo.txt") && !s.matches("subdir1/excluded/foo.txt"));
// An existing directory covers the files below it; other names match files only.
let s = scope(root, &["subdir1/nested"]);
assert!(s.matches("subdir1/nested/foo.txt") && !s.matches("subdir1/other/foo.txt"));
let s = scope(root, &["!gen"]);
assert!(s.matches("src/gen/a.rs") && !s.matches("src/gen"));
let s = scope(root, &["*.rs"]);
assert!(s.matches("a.rs") && s.matches("x/y/a.rs") && !s.matches("a.ts"));
assert!(s.matches("/abs/content/root/a.rs"));
let s = scope(root, &["!*.md"]);
assert!(s.matches("a.rs") && !s.matches("docs/a.md"));
assert!(scope(root, &["*.{rs,ts}"]).masks.is_some());
assert!(scope(root, &["*.{rs,{ts,js}}"]).masks.is_none());
let err = |p: &str| PathScope::new(root, &[p.to_owned()]).err().unwrap().message;
assert!(err("a[").contains("Invalid glob pattern"));
assert!(err("!").contains("Exclude pattern is empty"));
assert!(err("../x/**").contains("outside of the project directory"));
}
}
+264
View File
@@ -0,0 +1,264 @@
//! Quick Documentation through the project's language services.
use super::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult, Pos, Range};
use ide_lsp::types::GotoKind;
use serde::Serialize;
use serde_json::json;
use std::path::Path;
pub fn get_symbol_info() -> Tool {
Tool::new("get_symbol_info", "Retrieves information about the symbol at the specified position in the specified file.
Provides the same information as the Quick Documentation feature of the IDE does (the language server's hover).
This tool is useful for getting information about the symbol at the specified position in the specified file.
The information may include the symbol's name, signature, type, documentation, etc. It depends on a particular language.
If the position has a reference to a symbol the tool will return a piece of code with the declaration of the symbol if possible.
Use this tool to understand symbols declaration, semantics, where it's declared, etc.", get_info)
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::required("line", ParamType::Integer, "1-based line number"))
.param(Param::required("column", ParamType::Integer, "1-based UTF-16 column number"))
}
async fn get_info(call: Call) -> IdeResult<Output> {
let path = call.resolve_readable(&call.str("filePath")?).await?;
let line = call.opt_i64("line")?.ok_or_else(|| IdeError::invalid("missing line"))?;
let col = call.opt_i64("column")?.ok_or_else(|| IdeError::invalid("missing column"))?;
let project = call.project.clone();
let p = path.clone();
struct Document(std::sync::Arc<crate::Project>, ide_core::DocId);
impl Drop for Document {
fn drop(&mut self) {
self.0.documents.close(self.1);
}
}
let (text, document) = call
.host
.blocking(move || {
let text = project.text_of(&p)?;
let doc = project.documents.open(&p)?;
Ok((text, Document(project, doc.id)))
})
.await?;
let doc = document.1;
if line < 1 || line > u32::MAX as i64 {
return Err(IdeError::invalid("line is out of bounds"));
}
let line_text = usize::try_from(line.saturating_sub(1))
.ok()
.and_then(|l| text.split('\n').nth(l))
.ok_or_else(|| IdeError::invalid("Line number is out of bounds of the document"))?;
if col < 1 || col as usize > line_text.encode_utf16().count() + 1 {
return Err(IdeError::invalid("Column number is out of bounds of the document"));
}
let pos = Pos::new((line - 1) as u32, (col - 1) as u32);
let word = word_at(line_text, char_column(line_text, col as u32));
let mut output = json!({"documentation":""});
let work = async {
let hover = call.project.lsp.hover(doc, pos, call.cancel.clone()).await;
let definitions = call.project.lsp.goto(doc, pos, GotoKind::Definition).await;
Ok::<_, IdeError>((hover, definitions))
};
let result = tokio::select! {
result = tokio::time::timeout(std::time::Duration::from_secs(20), work) => result,
_ = call.cancel.cancelled() => return Err(IdeError::cancelled()),
};
match result {
Ok(Ok((hover, definitions))) => {
if let Ok(Some(hover)) = hover {
output["documentation"] = hover.contents.value.into();
}
if let Ok(definitions) = definitions
&& let Some(location) = definitions.iter().find(|l| l.uri.is_none())
&& let Some(info) = symbol_info(&call, &location.path, word.clone(), location.range, 1)
{
output["symbolInfo"] = serde_json::to_value(info).map_err(|e| IdeError::internal(e.to_string()))?;
}
}
Ok(Err(e)) => {
output["partialResultReason"] = e.message.into();
}
Err(_) => {
output["partialResultReason"] = "The language server did not answer in time; retry later.".into();
}
}
if output.get("symbolInfo").is_none()
&& let Some(word) = word
{
let project = call.project.clone();
let path = path.clone();
let found = call
.host
.blocking(move || {
let mut symbols = project.index.symbols_named(&word, None, 50);
symbols.sort_by_key(|s| s.path != path);
Ok(symbols.into_iter().next())
})
.await?;
if let Some(symbol) = found
&& let Some(info) = symbol_info(&call, &symbol.path, Some(symbol.name), symbol.range, 1)
{
output["symbolInfo"] = serde_json::to_value(info).map_err(|e| IdeError::internal(e.to_string()))?;
}
}
if call.project.index.status().indexing {
output["partialResultReason"] = super::util::INDEXING_PARTIAL_RESULT_REASON.into();
}
Ok(Output::text(output.to_string()))
}
/// The 1-based character index of 1-based UTF-16 `column` in `line`.
fn char_column(line: &str, column: u32) -> u32 {
let mut units = 0u32;
let mut chars = 0u32;
for c in line.chars() {
if units + 1 >= column {
break;
}
units += c.len_utf16() as u32;
chars += 1;
}
chars + 1
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '$'
}
/// The identifier at (or just before) 1-based character `column` of `line`.
fn word_at(line: &str, column: u32) -> Option<String> {
let chars: Vec<char> = line.chars().collect();
let mut at = (column as usize).saturating_sub(1).min(chars.len());
if !chars.get(at).is_some_and(|c| is_word(*c)) {
if at > 0 && is_word(chars[at - 1]) {
at -= 1;
} else {
return None;
}
}
let start = (0..=at).rev().take_while(|i| is_word(chars[*i])).last()?;
let end = (at..chars.len()).take_while(|i| is_word(chars[*i])).last()? + 1;
Some(chars[start..end].iter().collect())
}
/// Lines of comments and annotations above a declaration that count as part of it.
const MAX_LEADING_LINES: usize = 30;
/// JetBrains' `SymbolInfo`.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SymbolInfo {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
declaration_text: String,
#[serde(skip_serializing_if = "Option::is_none")]
declaration_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
declaration_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
language: Option<String>,
}
/// JetBrains' `getElementSymbolInfo` over a declaration's name range: from the comments and
/// annotations right above it to the line where the name ends, plus `extra_lines`.
fn symbol_info(
call: &Call,
path: &Path,
name: Option<String>,
name_range: Range,
extra_lines: u32,
) -> Option<SymbolInfo> {
let project = call.project();
let text = project.text_of(path).ok()?;
let text = text.replace("\r\n", "\n");
let lines: Vec<&str> = text.split('\n').collect();
let name_line = name_range.start.line as usize;
if name_line >= lines.len() {
return None;
}
let language = project.languages.detect(path, lines.first().copied());
let config = project.languages.get(&language);
let line_comment = config.as_ref().and_then(|c| c.line_comment.clone());
let mut start = name_line;
while start > 0
&& name_line - start < MAX_LEADING_LINES
&& leads_declaration(lines[start - 1], line_comment.as_deref())
{
start -= 1;
}
let end = (name_range.end.line.max(name_range.start.line) as usize + extra_lines as usize).min(lines.len() - 1);
Some(SymbolInfo {
name,
declaration_text: format!("<…>\n{}\n<…>", lines[start..=end].join("\n")),
declaration_file: Some(call.display(path)),
declaration_line: Some(name_line as u32 + 1),
language: config.map(|c| c.name),
})
}
/// Doc comments, comments and annotations / attributes / decorators belong to the declaration below.
fn leads_declaration(line: &str, line_comment: Option<&str>) -> bool {
let t = line.trim_start();
if t.is_empty() {
return false;
}
["//", "/*", "*", "#[", "#![", "@"].iter().any(|p| t.starts_with(p))
|| line_comment.is_some_and(|c| !c.is_empty() && t.starts_with(c))
}
pub fn find_references() -> Tool {
Tool::new("find_references", "Find references using the language service and the IDE's project index. Positions are 1-based UTF-16. Reports incomplete results when analysis is still running.", |call| async move {
let path = call.resolve_readable(&call.str("filePath")?).await?;
let line = call.opt_i64("line")?.ok_or_else(|| IdeError::invalid("missing line"))?;
let column = call.opt_i64("column")?.ok_or_else(|| IdeError::invalid("missing column"))?;
let project = call.project.clone();
let (doc, text) = call.host.blocking(move || {
let text = project.text_of(&path)?;
let doc = project.documents.open(&path)?;
Ok((doc.id, text))
}).await?;
struct Close(std::sync::Arc<crate::Project>, ide_core::DocId);
impl Drop for Close { fn drop(&mut self) { self.0.documents.close(self.1); } }
let _close = Close(call.project.clone(), doc);
let pos = mcp_position(&text, line, column)?;
let request = super::tool::request_id();
let work = crate::ops::lsp::lsp_references(&call.host, &call.window, request, doc, pos, call.flag("includeDeclaration", true)?, true, false, crate::ops::Channel::new(|_| {}));
tokio::pin!(work);
let result = tokio::select! {
result = &mut work => result?,
_ = call.cancel.cancelled() => { call.host.cancel_request(request); let _ = work.await; return Err(IdeError::cancelled()); }
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { call.host.cancel_request(request); let _ = work.await; return Err(IdeError::unavailable("references timed out; retry when analysis finishes")); }
};
let mut value = serde_json::to_value(&result).map_err(|e| IdeError::internal(e.to_string()))?;
// Convert every Location range at this boundary, including declarations.
fn positions(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
if let (Some(line), Some(col)) = (map.get("line").and_then(|v| v.as_u64()), map.get("col").and_then(|v| v.as_u64())) {
map.insert("line".into(), (line + 1).into()); map.insert("col".into(), (col + 1).into());
} else { for v in map.values_mut() { positions(v); } }
}
serde_json::Value::Array(items) => { for v in items { positions(v); } }
_ => {}
}
}
positions(&mut value);
Output::json(value)
})
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::required("line", ParamType::Integer, "1-based line"))
.param(Param::required("column", ParamType::Integer, "1-based UTF-16 column"))
.param(Param::optional("includeDeclaration", ParamType::Boolean, "Include declarations (default true)"))
}
fn mcp_position(text: &str, line: i64, column: i64) -> IdeResult<Pos> {
if line < 1 || line > u32::MAX as i64 || column < 1 || column > u32::MAX as i64 {
return Err(IdeError::invalid("line and column must be positive 1-based positions"));
}
let row = text.split('\n').nth((line - 1) as usize).ok_or_else(|| IdeError::invalid("line is out of bounds"))?;
if column as usize > row.encode_utf16().count() + 1 {
return Err(IdeError::invalid("column is out of bounds"));
}
Ok(Pos::new((line - 1) as u32, (column - 1) as u32))
}
+297
View File
@@ -0,0 +1,297 @@
use crate::{Host, Project};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::Serialize;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct Call {
pub client: String,
pub tool: String,
pub host: Arc<Host>,
pub project: Arc<Project>,
pub window: String,
pub args: Value,
pub cancel: CancellationToken,
}
impl Call {
pub fn opt_str(&self, name: &str) -> IdeResult<Option<String>> {
self.args
.get(name)
.filter(|v| !v.is_null())
.map(|v| v.as_str().map(str::to_owned).ok_or_else(|| IdeError::invalid(format!("{name} must be a string"))))
.transpose()
}
pub fn str_map(&self, name: &str) -> IdeResult<Vec<(String, String)>> {
self.args
.get(name)
.and_then(Value::as_object)
.ok_or_else(|| IdeError::invalid(format!("{name} must be an object")))?
.iter()
.map(|(k, v)| {
v.as_str()
.map(|v| (k.clone(), v.to_owned()))
.ok_or_else(|| IdeError::invalid(format!("{name} must contain strings")))
})
.collect()
}
pub fn resolve_inside(&self, path: &str) -> IdeResult<PathBuf> {
resolve_inside(self.root(), path)
}
pub async fn resolve_writable(&self, path: &str) -> IdeResult<PathBuf> {
let path = self.resolve_inside(path)?;
self.check_writable(&path).await?;
Ok(path)
}
pub async fn check_writable(&self, path: &Path) -> IdeResult<()> {
self.project.require_trust()?;
ensure_inside(self.root(), path)
}
pub async fn ask(&self, request: ide_core::event::McpUiRequest) -> IdeResult<()> {
self.host.mcp_requests.ask(self, request).await
}
pub async fn confirm(&self, subject: &str, detail: Option<String>) -> IdeResult<()> {
self.ask(ide_core::event::McpUiRequest::Confirm {
client: self.client.clone(),
tool: self.tool.clone(),
subject: subject.into(),
project: self.root().to_string_lossy().into_owned(),
detail,
})
.await?;
self.project.require_trust()
}
pub fn host(&self) -> Arc<Host> {
self.host.clone()
}
pub fn project(&self) -> &Arc<Project> {
&self.project
}
pub fn root(&self) -> &Path {
&self.project.root
}
pub fn window(&self) -> &str {
&self.window
}
pub fn str(&self, name: &str) -> IdeResult<String> {
self.args
.get(name)
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| IdeError::invalid(format!("missing or invalid argument `{name}`")))
}
pub fn opt_i64(&self, name: &str) -> IdeResult<Option<i64>> {
self.args
.get(name)
.map(|v| v.as_i64().ok_or_else(|| IdeError::invalid(format!("{name} must be an integer"))))
.transpose()
}
pub fn flag(&self, name: &str, default: bool) -> IdeResult<bool> {
self.args
.get(name)
.map(|v| v.as_bool().ok_or_else(|| IdeError::invalid(format!("{name} must be a boolean"))))
.unwrap_or(Ok(default))
}
pub fn str_list(&self, name: &str) -> IdeResult<Vec<String>> {
match self.args.get(name) {
None => Ok(Vec::new()),
Some(Value::Array(values)) => values
.iter()
.map(|v| {
v.as_str()
.map(str::to_owned)
.ok_or_else(|| IdeError::invalid(format!("{name} must contain strings")))
})
.collect(),
_ => Err(IdeError::invalid(format!("{name} must be an array"))),
}
}
pub fn display(&self, path: &Path) -> String {
path.strip_prefix(self.root()).unwrap_or(path).to_string_lossy().replace('\\', "/")
}
pub async fn resolve_readable(&self, path: &str) -> IdeResult<PathBuf> {
let root = self.root().to_path_buf();
let mut roots = self.project.content_roots();
roots.extend(self.host.mcp_reports.libraries(&self.host.mcp_windows(self.root())));
roots.extend(
self.project
.settings
.effective()
.project_structure
.libraries
.iter()
.flat_map(|l| l.sources.iter().chain(&l.classes).chain(&l.javadocs))
.map(|p| self.root().join(p)),
);
let path = path.to_owned();
self.host
.blocking(move || {
let path = if path.starts_with("file://") {
ide_lsp::uri_to_path(&path).ok_or_else(|| IdeError::invalid("invalid file URL"))?
} else {
PathBuf::from(path)
};
let path = if path.is_absolute() { path } else { root.join(path) };
let (local, entry) =
ide_vfs::split_archive_path(&path).map_or((path.clone(), None), |(p, e)| (p, Some(e)));
let real = if let Ok(real) = dunce::canonicalize(&local) {
real
} else {
let parent =
local.ancestors().find(|p| p.exists()).ok_or_else(|| IdeError::not_found("path not found"))?;
normalize(
&dunce::canonicalize(parent)?
.join(local.strip_prefix(parent).map_err(|e| IdeError::invalid(e.to_string()))?),
)
};
if !std::iter::once(root).chain(roots).any(|r| {
let r = ide_vfs::split_archive_path(&r).map_or(r.clone(), |(archive, _)| archive);
dunce::canonicalize(r).is_ok_and(|r| real.starts_with(r))
}) {
return Err(IdeError::invalid("path is outside the project's content roots"));
}
Ok(entry.map_or_else(|| real.clone(), |entry| PathBuf::from(format!("{}!/{entry}", real.display()))))
})
.await
}
}
pub struct Output(pub String);
impl Output {
pub fn text(text: impl Into<String>) -> Self {
Self(text.into())
}
pub fn json(value: impl Serialize) -> IdeResult<Self> {
serde_json::to_string(&value).map(Self).map_err(|e| IdeError::internal(e.to_string()))
}
}
#[derive(Clone, Copy)]
pub enum ParamType {
String,
Integer,
Boolean,
StringList,
StringMap,
}
pub struct Param {
name: &'static str,
kind: ParamType,
description: &'static str,
required: bool,
}
impl Param {
pub fn required(name: &'static str, kind: ParamType, description: &'static str) -> Self {
Self { name, kind, description, required: true }
}
pub fn optional(name: &'static str, kind: ParamType, description: &'static str) -> Self {
Self { name, kind, description, required: false }
}
}
type Handler = Arc<dyn Fn(Call) -> futures::future::BoxFuture<'static, IdeResult<Output>> + Send + Sync>;
pub struct Tool {
pub metadata: ide_mcp::Tool,
pub handler: Handler,
}
impl Tool {
pub fn new<F, Fut>(name: &str, description: &str, handler: F) -> Self
where
F: Fn(Call) -> Fut + Send + Sync + 'static,
Fut: Future<Output = IdeResult<Output>> + Send + 'static,
{
Self {
metadata: ide_mcp::Tool {
name: name.into(),
description: description.into(),
read_only: true,
destructive: false,
output_schema: None,
input_schema: json!({ "type": "object", "properties": {
"projectPath": { "type": "string", "description": "Any directory inside an open project. Optional when only one project is open." }
}, "required": [] }),
},
handler: Arc::new(move |call| Box::pin(handler(call))),
}
}
pub fn write(mut self) -> Self {
self.metadata.read_only = false;
self
}
pub fn param(mut self, param: Param) -> Self {
let mut schema = match param.kind {
ParamType::String => json!({"type":"string"}),
ParamType::Integer => json!({"type":"integer"}),
ParamType::Boolean => json!({"type":"boolean"}),
ParamType::StringMap => json!({"type":"object","additionalProperties":{"type":"string"}}),
ParamType::StringList => json!({"type":"array","items":{"type":"string"}}),
};
schema["description"] = param.description.into();
self.metadata.input_schema["properties"][param.name] = schema;
if param.required {
self.metadata.input_schema["required"].as_array_mut().unwrap().push(param.name.into());
}
self
}
pub fn output(mut self, schema: Value) -> Self {
self.metadata.output_schema = Some(schema);
self
}
}
pub fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for part in path.components() {
match part {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
p => out.push(p.as_os_str()),
}
}
out
}
pub fn request_id() -> ide_core::RequestId {
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0x8000_0000);
ide_core::RequestId(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
}
/// Resolve existing ancestors too, so symlinks cannot escape the project.
pub fn resolve_inside(root: &Path, raw: &str) -> IdeResult<PathBuf> {
let path = if raw.starts_with("file://") {
ide_lsp::uri_to_path(raw).ok_or_else(|| IdeError::invalid("invalid file URL"))?
} else {
PathBuf::from(raw)
};
let path = if path.is_absolute() { path } else { root.join(path) };
if path.components().any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(".git")) {
return Err(IdeError::invalid("VCS metadata cannot be changed"));
}
let path = normalize(&path);
let ancestor =
path.ancestors().find(|p| p.exists()).ok_or_else(|| IdeError::invalid("path has no existing parent"))?;
let mut real = dunce::canonicalize(ancestor)?;
let suffix = path.strip_prefix(ancestor).map_err(|e| IdeError::invalid(e.to_string()))?;
if !suffix.as_os_str().is_empty() {
real.push(suffix);
}
let root = dunce::canonicalize(root)?;
if !real.starts_with(&root) {
return Err(IdeError::invalid("path is outside the project"));
}
if real
.strip_prefix(&root)
.unwrap()
.components()
.any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(".git"))
{
return Err(IdeError::invalid("VCS metadata cannot be changed"));
}
Ok(real)
}
pub fn ensure_inside(root: &Path, path: &Path) -> IdeResult<()> {
resolve_inside(root, &path.to_string_lossy()).map(|_| ())
}
+69
View File
@@ -0,0 +1,69 @@
// Ported from JetBrains intellij-community plugins/mcp-server (src/com/intellij/mcpserver/util/fs.util.kt,
// util/text.util.kt, toolsets/Constants.kt), Apache-2.0.
//! Helpers shared by the tools: the strings and timeouts of JetBrains' `Constants`,
//! resolving the paths `read_file` accepts, and text truncation.
use crate::mcp::tool::Call;
use ide_core::{IdeError, IdeResult};
use std::path::PathBuf;
use std::time::Duration;
pub const RELATIVE_PATH_IN_PROJECT_DESCRIPTION: &str = "Path relative to the project root";
pub const FILE_PATH_DESCRIPTION: &str = "Path to the file. Supports project-relative paths, paths with '..', \
absolute paths, archive entries like '/path/lib.jar!/pkg/Foo.java', and URLs such as 'file://' and 'jar://'. \
Any path returned from the other tools can be passed as is (e.g. paths from 'search_*' tools).";
pub const TIMEOUT_MILLISECONDS_DESCRIPTION: &str = "Timeout in milliseconds";
pub const TIMED_OUT_DESCRIPTION: &str = "Indicates whether the operation was timed out. 'true' value may mean that \
the results may be incomplete or partial. 'false', 'null' or missing value means that the operation has not been \
timed out.";
pub const MEDIUM_TIMEOUT: Duration = Duration::from_secs(10);
pub const INDEXING_PARTIAL_RESULT_REASON: &str =
"Project indexing was in progress; results may be incomplete — retry once indexing completes.";
/// Whether `path` starts with a URL scheme (`file://`, `jar://`, `jrt://` …).
pub fn looks_like_vfs_url(path: &str) -> bool {
path.find("://")
.is_some_and(|i| i > 0 && path[..i].chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')))
}
/// Resolves `read_file`'s `file_path`: project-relative or absolute paths, archive entries
/// (`/lib.jar!/pkg/Foo.java`) and `file://` / `jar://` URLs, within known content or library roots.
pub async fn resolve_read_file(call: &Call, file_path: &str) -> IdeResult<PathBuf> {
let normalized = file_path.trim().replace('\\', "/");
if normalized.is_empty() {
return Err(IdeError::invalid("file_path is empty"));
}
let missing = || IdeError::not_found(format!("File {file_path} doesn't exist or can't be opened"));
let local = if looks_like_vfs_url(&normalized) {
let (scheme, rest) = normalized.split_once("://").unwrap_or_default();
match scheme.to_ascii_lowercase().as_str() {
// `file://` URLs are percent-decoded by `resolve`.
"file" => normalized.clone(),
"jar" => rest.to_owned(),
"jrt" => {
return Err(IdeError::invalid(format!(
"File {file_path} can't be opened: OpenIDEA does not read jrt:// (Java runtime image) entries"
)));
}
_ => return Err(missing()),
}
} else {
normalized
};
let path = call.resolve_readable(&local).await?;
match ide_vfs::split_archive_path(&path) {
Some((archive, entry)) => {
if !archive.is_file() {
return Err(missing());
}
if entry.is_empty() || ide_vfs::read_archive_dir(&archive, &entry).is_ok() {
return Err(IdeError::invalid(format!("File {file_path} is a directory")));
}
ide_vfs::probe(&path).map_err(|_| missing())?;
}
None if path.is_dir() => return Err(IdeError::invalid(format!("File {file_path} is a directory"))),
None if !path.is_file() && call.project().documents.by_path(&path).is_none() => return Err(missing()),
None => {}
}
Ok(path)
}
+362
View File
@@ -0,0 +1,362 @@
// Ported from JetBrains intellij-community plugins/mcp-server
// (mcpserver.vcs/src/com/intellij/mcpserver/toolsets/vcs/VcsToolset.kt), Apache-2.0.
//! Version control: repositories and git status.
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
const STATUS_DEFAULT_LIMIT: i64 = 1000;
pub fn tools() -> Vec<Tool> {
vec![get_repositories(), git_status(), git_diff()]
}
/// `path` with symbolic links resolved (macOS `/var` is `/private/var`), as given when it
/// does not exist.
fn canonical(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
/// A path relative to the project root with `/`; empty for the root itself. Either may
/// name the other through a symbolic link.
fn relative_to(root: &Path, path: &Path) -> String {
let rel = match path.strip_prefix(root) {
Ok(rel) => Some(rel.to_path_buf()),
Err(_) => canonical(path).strip_prefix(canonical(root)).ok().map(Path::to_path_buf),
};
match rel {
Some(rel) => rel.to_string_lossy().replace('\\', "/"),
None => path.to_string_lossy().into_owned(),
}
}
fn relative_to_project(call: &Call, path: &Path) -> String {
relative_to(call.root(), path)
}
/// The repository whose roots contain `path` most specifically (paths compared canonically).
fn innermost_repo<T>(repos: Vec<(PathBuf, T)>, path: &Path) -> Option<(PathBuf, T)> {
let path = canonical(path);
repos.into_iter().filter(|r| path.starts_with(canonical(&r.0))).max_by_key(|r| canonical(&r.0).components().count())
}
// Read tools must never invoke repository-configured executables, including in Restricted Mode.
fn read_command(git: &str, root: &Path) -> std::process::Command {
let mut command = std::process::Command::new(git);
command
.current_dir(root)
.args([
"-c",
"core.fsmonitor=false",
"-c",
"core.hooksPath=",
"-c",
"diff.external=",
"-c",
"core.attributesFile=",
"-c",
"submodule.recurse=false",
"-c",
"color.ui=false",
])
.env("GIT_NO_LAZY_FETCH", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_OPTIONAL_LOCKS", "0")
.env_remove("GIT_EXTERNAL_DIFF")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE");
ide_core::process::prepare(&mut command);
command
}
/// The branch HEAD is on (unborn too); `None` when HEAD is detached or git fails.
fn read_branch(git: &str, root: &Path) -> Option<String> {
let mut cmd = read_command(git, root);
cmd.args(["symbolic-ref", "--short", "-q", "HEAD"]);
ide_core::process::prepare(&mut cmd);
let out = cmd.output().ok().filter(|o| o.status.success())?;
Some(String::from_utf8_lossy(&out.stdout).trim().to_owned()).filter(|b| !b.is_empty())
}
fn get_repositories() -> Tool {
Tool::new(
"get_repositories",
"Retrieves the list of VCS roots in the project.
This is useful to detect all repositories in a multi-repository project.
In OpenIDEA each root also tells its current `branch` (absent when HEAD is detached), `headCommit` and a merge, \
rebase or cherry-pick in progress (`operationInProgress`).",
|call| async move {
let host = call.host();
let window = call.window.clone();
let roots = call.host.blocking(move || crate::ops::vcs::vcs_roots(&host, &window)).await?;
let list: Vec<Value> = roots
.into_iter()
.map(|r| {
let mut v =
json!({ "pathRelativeToProject": relative_to_project(&call, &r.root), "vcsName": "Git" });
if let Some(head) = r.head {
v["branch"] = head.into();
}
if let Some(commit) = r.head_commit {
v["headCommit"] = commit.into();
}
if let Some(op) = r.operation {
v["operationInProgress"] = op.into();
}
v
})
.collect();
Output::json(json!({ "roots": list }))
},
)
}
/// One entry of `git status --porcelain -z`.
#[derive(Debug, PartialEq)]
struct StatusEntry {
index: char,
work_tree: char,
path: String,
orig_path: Option<String>,
}
impl StatusEntry {
fn untracked(&self) -> bool {
self.index == '?'
}
fn ignored(&self) -> bool {
self.index == '!'
}
fn conflicted(&self) -> bool {
matches!((self.index, self.work_tree), ('D', 'D') | ('A', 'U') | ('U', _) | ('A', 'A') | ('D', 'U'))
}
fn staged(&self) -> bool {
!self.conflicted() && !matches!(self.index, ' ' | '?' | '!')
}
fn unstaged(&self) -> bool {
!self.conflicted() && !matches!(self.work_tree, ' ' | '?' | '!')
}
}
fn parse_status(out: &str) -> Vec<StatusEntry> {
let mut fields = out.split('\0').filter(|f| !f.is_empty());
let mut entries = Vec::new();
while let Some(field) = fields.next() {
let mut chars = field.chars();
let (Some(index), Some(work_tree)) = (chars.next(), chars.next()) else { continue };
let path = field.get(3..).unwrap_or_default().to_owned();
let orig_path = matches!(index, 'R' | 'C').then(|| fields.next().map(str::to_owned)).flatten();
entries.push(StatusEntry { index, work_tree, path, orig_path });
}
entries
}
fn git_executable(call: &Call) -> String {
let settings = if call.project.is_trusted() {
call.project.settings.effective()
} else {
call.host.global_settings.effective()
};
let exe = settings.vcs.git.executable.trim().to_owned();
if exe.is_empty() { "git".to_owned() } else { exe }
}
/// `git status` of the repository at `root`, paths relative to it.
fn read_status(git: &str, root: &Path, untracked: bool, ignored: bool) -> IdeResult<Vec<StatusEntry>> {
let mut cmd = read_command(git, root);
cmd.args(["-c", "core.quotepath=false", "status", "--porcelain", "-z", "--find-renames"]);
// git refuses `--ignored=matching` with `--untracked-files=no`: untracked files are dropped below.
cmd.arg(if untracked || ignored { "--untracked-files=all" } else { "--untracked-files=no" });
if ignored {
cmd.arg("--ignored=matching");
}
ide_core::process::prepare(&mut cmd);
let out = cmd.output().map_err(|e| IdeError::unavailable(format!("cannot run git: {e}")))?;
if !out.status.success() {
return Err(IdeError::internal(String::from_utf8_lossy(&out.stderr).trim().to_owned()));
}
let mut entries = parse_status(&String::from_utf8_lossy(&out.stdout));
entries.retain(|e| untracked || !e.untracked());
Ok(entries)
}
/// A git repository in the project, for paths outside the repositories IDEA knows: the
/// closest folder with `.git` from `start` up to the project root.
fn find_git_root(root: &Path, start: &Path) -> Option<PathBuf> {
let (root, start) = (canonical(root), canonical(start));
let mut current = if start.is_dir() { start.as_path() } else { start.parent().unwrap_or(&start) };
while current.starts_with(&root) {
if current.join(".git").exists() {
return Some(current.to_path_buf());
}
current = current.parent()?;
}
None
}
fn git_status() -> Tool {
Tool::new(
"git_status",
"Retrieves Git status for one or more repositories in the current project.
Returns porcelain-style index/worktree status codes and summary counters.
By default all Git repositories are returned.",
|call| async move {
let include_untracked = call.flag("includeUntracked", true)?;
let include_ignored = call.flag("includeIgnored", false)?;
let limit = call.opt_i64("limit")?.unwrap_or(STATUS_DEFAULT_LIMIT);
if limit <= 0 {
return Err(IdeError::invalid("limit must be > 0"));
}
let limit = limit as usize;
let host = call.host();
let h = host.clone();
let w = call.window.clone();
let repos: Vec<(PathBuf, Option<String>)> = host
.blocking(move || crate::ops::vcs::vcs_roots(&h, &w))
.await?
.into_iter()
.map(|r| (r.root.into_path_buf(), r.head))
.collect();
let wanted = call.opt_str("repositoryPathRelativeToProject")?;
let git = git_executable(&call);
let mut roots: Vec<(PathBuf, Option<String>)> = match &wanted {
None => repos,
Some(wanted) => {
let path = call.resolve_inside(wanted)?;
// The innermost repository containing the path.
match innermost_repo(repos, &path) {
Some(repo) => vec![repo],
None => match find_git_root(call.root(), &path) {
Some(root) => {
// JetBrains reads the branch of any repository it reports.
let (git, dir) = (git.clone(), root.clone());
let branch = host.blocking(move || Ok(read_branch(&git, &dir))).await?;
vec![(root, branch)]
}
None => {
return Err(IdeError::not_found(format!("No git repository found for path: {wanted}")));
}
},
}
}
};
roots.sort_by_key(|r| relative_to_project(&call, &r.0));
let mut repositories = Vec::new();
for (root, branch) in roots {
let rel = relative_to_project(&call, &root);
let (git, dir) = (git.clone(), root.clone());
let entries = host
.blocking(move || read_status(&git, &dir, include_untracked, include_ignored))
.await
.map_err(|e| {
IdeError::internal(format!("Failed to get git status for repository '{rel}': {}", e.message))
})?;
let count = |f: fn(&StatusEntry) -> bool| entries.iter().filter(|e| f(e)).count();
let listed: Vec<Value> = entries
.iter()
.take(limit)
.map(|e| {
let mut v = json!({
"pathRelativeToRepository": e.path.trim_end_matches('/'),
"indexStatus": e.index.to_string(),
"workTreeStatus": e.work_tree.to_string(),
});
if let Some(orig) = &e.orig_path {
v["originalPathRelativeToRepository"] = orig.clone().into();
}
v
})
.collect();
repositories.push(json!({
"repositoryPathRelativeToProject": rel,
"currentBranch": branch,
"isClean": entries.is_empty(),
"totalEntries": entries.len(),
"hasMoreEntries": entries.len() > listed.len(),
"stagedCount": count(StatusEntry::staged),
"unstagedCount": count(StatusEntry::unstaged),
"untrackedCount": count(StatusEntry::untracked),
"ignoredCount": count(StatusEntry::ignored),
"conflictedCount": count(StatusEntry::conflicted),
"entries": listed,
}));
}
Output::json(json!({ "repositories": repositories }))
},
)
.param(Param::optional(
"repositoryPathRelativeToProject",
ParamType::String,
"Optional path relative to project root used to select a single containing repository",
))
.param(Param::optional("includeUntracked", ParamType::Boolean, "Whether to include untracked files"))
.param(Param::optional("includeIgnored", ParamType::Boolean, "Whether to include ignored files"))
.param(Param::optional("limit", ParamType::Integer, "Maximum number of entries returned per repository"))
}
fn git_diff() -> Tool {
Tool::new("git_diff", "Read a Git diff for a file or the working tree. staged=true compares the index with HEAD; otherwise compares disk with the index. Unsaved editor text is not part of this diff.", |call| async move {
let wanted = call.opt_str("repositoryPathRelativeToProject")?;
let path = wanted.as_deref().map(|p| call.resolve_inside(p)).transpose()?.unwrap_or_else(|| call.root().to_path_buf());
let root = find_git_root(call.root(), &path).ok_or_else(|| IdeError::not_found("No git repository found"))?;
let file = call.opt_str("filePath")?.map(|p| call.resolve_inside(&p)).transpose()?;
if file.as_ref().is_some_and(|p| !p.starts_with(&root)) { return Err(IdeError::invalid("file is outside the repository")); }
let staged = call.flag("staged", false)?;
let git = git_executable(&call);
let cancel = call.cancel.clone();
let text = call.host.blocking(move || {
let mut cmd = read_command(&git, &root);
cmd.args(["--no-pager", "diff", "--no-ext-diff", "--no-textconv"]);
if staged { cmd.arg("--cached"); }
cmd.arg("--");
if let Some(file) = file { cmd.arg(file.strip_prefix(&root).map_err(|e| IdeError::invalid(e.to_string()))?); }
ide_core::process::prepare(&mut cmd);
if cancel.is_cancelled() { return Err(IdeError::cancelled()); }
let out = cmd.output()?;
if !out.status.success() { return Err(IdeError::unavailable(String::from_utf8_lossy(&out.stderr).trim())); }
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}).await?;
Ok(Output::text(text))
})
.param(Param::optional("repositoryPathRelativeToProject", ParamType::String, "Select the containing repository"))
.param(Param::optional("filePath", ParamType::String, "Optional project-relative file; omit for the working tree"))
.param(Param::optional("staged", ParamType::Boolean, "Show staged changes"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_porcelain_z_with_renames() {
let out = "M a.txt\0R new.txt\0old.txt\0?? u.txt\0UU c.txt\0";
let e = parse_status(out);
assert_eq!(e.len(), 4);
assert_eq!(e[1].path, "new.txt");
assert_eq!(e[1].orig_path.as_deref(), Some("old.txt"));
assert!(e[0].staged() && !e[0].unstaged());
assert!(e[2].untracked() && !e[2].staged());
assert!(e[3].conflicted() && !e[3].staged() && !e[3].unstaged());
}
#[cfg(unix)]
#[test]
fn paths_through_a_symlinked_root_compare_canonically() {
// As macOS `/var` → `/private/var`: the project opened through the link, git reporting real paths.
let dir = tempfile::tempdir().unwrap();
let real = dunce::canonicalize(dir.path()).unwrap().join("real");
std::fs::create_dir_all(real.join("sub/.git")).unwrap();
std::fs::write(real.join("sub/f.txt"), "x").unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert_eq!(relative_to(&link, &real.join("sub")), "sub");
assert_eq!(relative_to(&real, &link.join("sub")), "sub");
assert_eq!(relative_to(&link, &link), "");
let repos = vec![(real.clone(), 1), (real.join("sub"), 2)];
assert_eq!(innermost_repo(repos, &link.join("sub/f.txt")).map(|r| r.1), Some(2));
assert_eq!(find_git_root(&link, &real.join("sub/f.txt")), Some(real.join("sub")));
}
}
+7 -1
View File
@@ -207,7 +207,13 @@ pub async fn sdk_project_libraries(
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(ide_sdk::libraries::merge_groups([libraries.map_err(join_err)?, dependencies.map_err(join_err)?].concat()))
let groups =
ide_sdk::libraries::merge_groups([libraries.map_err(join_err)?, dependencies.map_err(join_err)?].concat());
if host.project(window).is_ok_and(|current| std::sync::Arc::ptr_eq(&current, &project)) {
host.mcp_reports
.set_libraries(window, groups.iter().flat_map(|g| g.roots.iter().map(|r| r.path.0.clone())).collect());
}
Ok(groups)
}
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
+418
View File
@@ -0,0 +1,418 @@
use ide_core::{CancellationToken, ChangeSet, Diagnostic, IdeEvent, Pos, Range, Severity, TextChange};
use ide_host::mcp::{FileProblems, FocusedEditor, Registry};
use ide_host::{Host, HostConfig};
use ide_mcp::ToolRegistry;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
struct Harness {
host: Arc<Host>,
root: PathBuf,
tmp: tempfile::TempDir,
events: Arc<std::sync::Mutex<Vec<IdeEvent>>>,
}
impl Harness {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/a.txt"), "old text\nneedle needle\n").unwrap();
std::fs::write(root.join("src/b.txt"), "needle\n").unwrap();
std::fs::create_dir_all(root.join("ignored")).unwrap();
std::fs::write(root.join("ignored/a.txt"), "needle\n").unwrap();
std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = events.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let events = sink.clone();
Arc::new(move |e| events.lock().unwrap().push(e))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", &root).unwrap();
Self { host, root, tmp, events }
}
async fn trust(&self) {
self.host.set_project_trust(&self.root, true, false).await.unwrap();
}
async fn error(&self, name: &str, args: Value) -> String {
let (host, root, name) = (self.host.clone(), self.root.clone(), name.to_owned());
let e = tokio::task::spawn_blocking(move || {
Registry::new(host).call(&root, &name, &args.to_string(), CancellationToken::new())
})
.await
.unwrap()
.unwrap_err();
match e {
ide_mcp::ToolCallError::Failed(e) | ide_mcp::ToolCallError::UnknownTool(e) => e,
}
}
async fn request(&self) -> u32 {
tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
if let Some(id) = self.events.lock().unwrap().iter().rev().find_map(|e| match e {
IdeEvent::McpRequest { id, .. } => Some(*id),
_ => None,
}) {
return id;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await
.unwrap()
}
async fn call(&self, name: &str, args: Value) -> String {
let (host, root, name) = (self.host.clone(), self.root.clone(), name.to_owned());
tokio::task::spawn_blocking(move || {
Registry::new(host).call(&root, &name, &args.to_string(), CancellationToken::new())
})
.await
.unwrap()
.unwrap()
}
async fn json(&self, name: &str, args: Value) -> Value {
serde_json::from_str(&self.call(name, args).await).unwrap()
}
}
impl Drop for Harness {
fn drop(&mut self) {
self.host.close_project("main");
self.host.close_project("second");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
let h = Harness::new();
let p = h.host.project("main").unwrap();
let doc = p.documents.open(&h.root.join("src/a.txt")).unwrap();
p.documents
.edit(
doc.id,
doc.version,
ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: "fresh needle".into() }] },
)
.unwrap();
assert_eq!(
h.call("read_file", json!({"file_path":"src/a.txt","offset":1,"limit":5})).await,
"L1: fresh needle text\nL2: needle needle\nL3: "
);
let found = h.json("search_text", json!({"q":"needle","limit":2})).await;
assert_eq!(found["items"].as_array().unwrap().len(), 2);
assert_eq!(found["more"], true);
let all = h.json("search_text", json!({"q":"fresh","limit":20})).await;
assert_eq!(all["items"][0]["filePath"], "src/a.txt");
let all = h.json("search_text", json!({"q":"needle","limit":20})).await;
assert!(all["items"].as_array().unwrap().iter().all(|v| !v["filePath"].as_str().unwrap().contains("ignored")));
let files = h.json("search_file", json!({"q":"*.txt","paths":["src/**"]})).await;
assert_eq!(files["items"], json!([{"filePath":"src/a.txt"},{"filePath":"src/b.txt"}]));
let regex = h.json("search_regex", json!({"q":"fresh\\s+needle"})).await;
assert_eq!(regex["items"][0]["startColumn"], 1);
assert_eq!(std::fs::read_to_string(h.root.join("src/a.txt")).unwrap(), "old text\nneedle needle\n");
}
#[tokio::test(flavor = "multi_thread")]
async fn window_reports_supply_editor_selection_and_problems_then_disappear_on_close() {
let h = Harness::new();
let path = h.root.join("src/a.txt");
h.host.mcp_reports.set_open_editors("main", vec![path.clone()]);
h.host.mcp_reports.set_focused_editor(
"main",
Some(FocusedEditor {
path: path.clone().into(),
selection: Range::new(Pos::new(1, 2), Pos::new(1, 4)),
selected_text: "ed".into(),
}),
true,
);
let d = Diagnostic {
range: Range::point(Pos::new(1, 2)),
severity: Severity::Warning,
message: "reported warning".into(),
source: Some("inspection".into()),
code: None,
unnecessary: false,
deprecated: false,
server: None,
};
h.host.mcp_reports.report_problems("main", true, vec![FileProblems { path: path.clone().into(), items: vec![d] }]);
assert_eq!(h.json("get_all_open_file_paths", json!({})).await["openFiles"], json!(["src/a.txt"]));
let focused = h.json("get_current_file", json!({})).await;
assert_eq!(focused["selectedText"], "ed");
assert_eq!(focused["selection"]["startColumn"], 3);
let problems = h.json("get_file_problems", json!({"filePath":"src/a.txt","errorsOnly":false})).await;
assert_eq!(problems["errors"][0]["description"], "reported warning");
assert_eq!(h.json("get_project_problems", json!({})).await["files"].as_array().unwrap().len(), 1);
h.host.close_project("main");
h.host.open_project("main", &h.root).unwrap();
assert_eq!(h.json("get_all_open_file_paths", json!({})).await["openFiles"], json!([]));
assert_eq!(h.json("get_current_file", json!({})).await, json!({}));
assert_eq!(h.json("get_project_problems", json!({})).await["files"], json!([]));
}
#[tokio::test(flavor = "multi_thread")]
async fn restricted_projects_have_only_read_tools_and_remote_projects_explain_the_limitation() {
let h = Harness::new();
assert!(!h.host.project("main").unwrap().is_trusted());
let registry = Registry::new(h.host.clone());
assert!(registry.tools(&h.root).unwrap().iter().all(|t| t.read_only && !t.destructive));
assert!(!h.host.global_settings.effective().mcp_server.enabled);
h.host.mcp_reports.set_remote("remote", PathBuf::from("/remote/project"));
assert_eq!(registry.tools(Path::new("/remote/project")).unwrap_err(), "not supported for remote projects yet");
h.host.close_project_detached("remote");
assert!(!registry.projects().contains(&PathBuf::from("/remote/project")));
}
#[tokio::test(flavor = "multi_thread")]
async fn project_path_argument_routes_to_another_open_project() {
let h = Harness::new();
let second = h.tmp.path().join("second");
std::fs::create_dir_all(second.join("nested")).unwrap();
std::fs::write(second.join("unique.txt"), "second project").unwrap();
h.host.open_project("second", &second).unwrap();
let discovery = ide_mcp::discovery_path(h.tmp.path());
let _server = ide_mcp::McpServer::start(
Arc::new(Registry::new(h.host.clone())),
Some(discovery.clone()),
&tokio::runtime::Handle::current(),
)
.unwrap();
let request = json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_file","arguments":{
"projectPath":second.join("nested"),"file_path":"unique.txt"}}});
let input = format!("{request}\n");
let mut output = Vec::new();
ide_mcp::stdio::bridge(&discovery, &h.root, input.as_bytes(), &mut output).await.unwrap();
let reply: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(reply["result"]["content"][0]["text"], "L1: second project");
}
#[tokio::test(flavor = "multi_thread")]
async fn mutations_preserve_unsaved_text_and_reject_metadata_and_escapes() {
let h = Harness::new();
h.trust().await;
let p = h.host.project("main").unwrap();
let path = h.root.join("src/a.txt");
let doc = p.documents.open(&path).unwrap();
p.documents
.edit(doc.id, doc.version, ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: "unsaved".into() }] })
.unwrap();
let output = h.call("apply_patch", json!({"input":"*** Begin Patch\n*** Update File: src/a.txt\n@@\n-unsaved text\n+agent text\n*** End Patch"})).await;
assert!(output.starts_with("1 out of 1"), "{output}");
assert_eq!(p.documents.text_snapshot(doc.id).unwrap().text, "agent text\nneedle needle\n");
assert!(p.documents.is_dirty(doc.id).unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "old text\nneedle needle\n");
assert!(
h.events.lock().unwrap().iter().any(|e| matches!(e, IdeEvent::DocPatched { doc: id, .. } if *id == doc.id))
);
let edit_id = h
.events
.lock()
.unwrap()
.iter()
.rev()
.find_map(|event| match event {
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
_ => None,
})
.unwrap();
p.documents.undo_workspace_edit(edit_id).unwrap();
assert_eq!(p.documents.text_snapshot(doc.id).unwrap().text, "unsaved text\nneedle needle\n");
p.documents.redo_workspace_edit(edit_id).unwrap();
assert_eq!(p.documents.text_snapshot(doc.id).unwrap().text, "agent text\nneedle needle\n");
h.call("create_new_file", json!({"pathInProject":"new/deep.txt","text":"hello\r\n"})).await;
// A new file gets the platform's default line separator.
let expected = if cfg!(windows) { "hello\r\n" } else { "hello\n" };
assert_eq!(std::fs::read_to_string(h.root.join("new/deep.txt")).unwrap(), expected);
h.call("create_new_file", json!({"pathInProject":"src/a.txt","text":"buffer only","overwrite":true})).await;
assert_eq!(p.documents.text_snapshot(doc.id).unwrap().text, "buffer only");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "old text\nneedle needle\n");
for path in [".git/config", "nested/.git/HEAD", "../escape.txt"] {
let e = h.error("create_new_file", json!({"pathInProject":path,"text":"no"})).await;
assert!(e.contains("VCS metadata") || e.contains("outside"), "{path}: {e}");
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(h.tmp.path(), h.root.join("escape")).unwrap();
assert!(h.error("create_new_file", json!({"pathInProject":"escape/outside.txt"})).await.contains("outside"));
}
}
#[tokio::test(flavor = "multi_thread")]
async fn execution_requires_a_window_and_denial_never_runs_a_build() {
let h = Harness::new();
h.trust().await;
assert!(h.error("build_project", json!({})).await.contains("no project window"));
h.host.mcp_requests.set_ready("main", true);
let denied = h.error("build_project", json!({}));
let answer = async {
let id = h.request().await;
assert!(h.host.mcp_requests.answer("other", id, true, None).is_err());
h.host.mcp_requests.answer("main", id, false, None).unwrap();
};
let (error, ()) = tokio::join!(denied, answer);
assert!(error.contains("denied by the user"), "{error}");
assert!(h.host.processes.list().is_empty());
assert!(h.events.lock().unwrap().iter().any(|e| matches!(e, IdeEvent::McpRequestClosed { .. })));
h.events.lock().unwrap().clear();
let closed = h.error("build_project", json!({}));
let close = async {
h.request().await;
h.host.mcp_requests.forget_window("main");
};
let (error, ()) = tokio::join!(closed, close);
assert!(error.contains("window closed"), "{error}");
}
#[tokio::test(flavor = "multi_thread")]
async fn git_status_and_diff_on_a_temporary_repository() {
let h = Harness::new();
let git = |args: &[&str]| {
let output = std::process::Command::new("git").current_dir(&h.root).args(args).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
};
git(&["init"]);
git(&["add", "."]);
git(&["-c", "user.name=Test", "-c", "[email protected]", "commit", "-m", "Initial"]);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let helper = h.tmp.path().join("fsmonitor");
std::fs::write(&helper, format!("#!/bin/sh\ntouch '{}'\n", h.tmp.path().join("executed").display())).unwrap();
std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o755)).unwrap();
git(&["config", "core.fsmonitor", helper.to_str().unwrap()]);
}
std::fs::write(h.root.join("src/a.txt"), "changed\n").unwrap();
let status = h.json("git_status", json!({"repositoryPathRelativeToProject":"."})).await;
assert_eq!(status["repositories"][0]["unstagedCount"], 1);
assert!(h.call("git_diff", json!({"filePath":"src/a.txt"})).await.contains("+changed"));
assert!(h.call("git_diff", json!({})).await.contains("src/a.txt"));
assert!(h.call("git_diff", json!({"staged":true})).await.is_empty());
assert!(!h.tmp.path().join("executed").exists());
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn reformat_uses_the_formatter_chain_and_preserves_a_dirty_buffer() {
use std::os::unix::fs::PermissionsExt;
let h = Harness::new();
h.trust().await;
std::fs::create_dir_all(h.root.join("vendor/bin")).unwrap();
std::fs::write(h.root.join("vendor/bin/php-cs-fixer"), "").unwrap();
std::fs::write(h.root.join(".php-cs-fixer.dist.php"), "<?php").unwrap();
let php = h.root.join("fake-php");
std::fs::write(&php, "#!/bin/sh\nfor last; do :; done\nsed 's/ echo/ echo/' \"$last\" > \"$last.new\" && mv \"$last.new\" \"$last\"\n").unwrap();
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
let p = h.host.project("main").unwrap();
p.settings.set(ide_workspace::SettingsScope::Project, "languages.php.interpreter", json!(php)).unwrap();
let path = h.root.join("src/Example.php");
std::fs::write(&path, "<?php\n echo 1;\n").unwrap();
let doc = p.documents.open(&path).unwrap();
p.documents
.edit(doc.id, doc.version, ChangeSet { changes: vec![TextChange { from: 13, to: 14, insert: "2".into() }] })
.unwrap();
let before = p.documents.text_snapshot(doc.id).unwrap().text;
let diff = h.call("reformat_file", json!({"files":["src/Example.php"]})).await;
assert!(diff.contains("+ echo"), "{diff}");
assert_eq!(p.documents.text_snapshot(doc.id).unwrap().text, before.replace(" echo", " echo"));
assert_eq!(std::fs::read_to_string(path).unwrap(), "<?php\n echo 1;\n");
assert!(p.documents.is_dirty(doc.id).unwrap());
}
#[tokio::test(flavor = "multi_thread")]
async fn execute_reports_output_exit_code_and_requires_confirmation() {
let h = Harness::new();
h.trust().await;
let config = ide_run::RunConfig {
id: "test".into(),
name: "MCP test".into(),
kind: "shell".into(),
command: if cfg!(windows) { "echo captured-output& exit 7" } else { "echo captured-output; exit 7" }.into(),
args: vec![],
cwd: None,
env: Default::default(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
ide_run::save_user_configs(&h.root, &[config]).unwrap();
assert!(
h.error("execute_run_configuration", json!({"configurationName":"MCP test"}))
.await
.contains("no project window")
);
h.host.mcp_requests.set_ready("main", true);
let denied = h.error("execute_run_configuration", json!({"configurationName":"MCP test"}));
let deny = async {
let id = h.request().await;
h.host.mcp_requests.answer("main", id, false, None).unwrap();
};
let (error, ()) = tokio::join!(denied, deny);
assert!(error.contains("denied by the user"));
assert!(h.host.processes.list().is_empty());
h.events.lock().unwrap().clear();
let run = h.json("execute_run_configuration", json!({"configurationName":"MCP test","timeout":10000}));
let allow = async {
let id = h.request().await;
h.host.mcp_requests.answer("main", id, true, None).unwrap();
};
let (result, ()) = tokio::join!(run, allow);
assert_eq!(result["exitCode"], 7);
assert!(result["output"].as_str().unwrap().contains("captured-output"));
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires rust-analyzer, like language_smoke::rust"]
async fn rust_references_and_rename_through_mcp() {
let h = Harness::new();
h.trust().await;
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/rust-app");
for name in ["Cargo.toml", "Cargo.lock"] {
std::fs::copy(fixture.join(name), h.root.join(name)).unwrap();
}
for name in ["lib.rs", "main.rs", "parser.rs"] {
std::fs::copy(fixture.join("src").join(name), h.root.join("src").join(name)).unwrap();
}
let project = h.host.project("main").unwrap();
let doc = project.documents.open(&h.root.join("src/lib.rs")).unwrap();
let text = project.documents.text_snapshot(doc.id).unwrap().text;
let (line, row) = text.lines().enumerate().find(|(_, row)| row.contains("fn format_price")).unwrap();
let column = row[..row.find("format_price").unwrap()].encode_utf16().count() + 1;
let args = json!({"filePath":"src/lib.rs", "line":line + 1, "column":column});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
loop {
let result = tokio::task::spawn_blocking({
let host = h.host.clone();
let root = h.root.clone();
let args = args.to_string();
move || Registry::new(host).call(&root, "find_references", &args, CancellationToken::new())
})
.await
.unwrap();
if let Ok(result) = result {
let refs: Value = serde_json::from_str(&result).unwrap();
if refs["locations"].as_array().is_some_and(|locations| {
locations.iter().any(|l| l["path"].as_str().is_some_and(|p| p.ends_with("main.rs")))
}) {
break;
}
}
assert!(std::time::Instant::now() < deadline, "language server did not find format_price references");
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
let result = h.json("rename_refactoring", json!({"pathInProject":"src/lib.rs", "symbolName":"format_price", "newName":"format_amount", "line":line + 1, "column":column})).await;
assert_eq!(result["applied"], true, "{result}");
assert!(project.documents.text_snapshot(doc.id).unwrap().text.contains("format_amount"));
assert!(std::fs::read_to_string(h.root.join("src/main.rs")).unwrap().contains("format_amount"));
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "ide-mcp"
description = "The IDE's MCP server: Streamable HTTP on 127.0.0.1 for AI agents outside the IDE, its discovery file and the stdio bridge"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
bytes = "1"
dunce.workspace = true
getrandom = "0.3"
http-body-util = "0.1"
hyper = { version = "1", features = ["client", "http1", "server"] }
hyper-util = { version = "0.1", default-features = false, features = ["tokio"] }
percent-encoding = "2"
serde.workspace = true
serde_json.workspace = true
tokio = { workspace = true, features = ["net", "io-std"] }
tracing.workspace = true
tokio-util.workspace = true
[dev-dependencies]
tempfile.workspace = true
tokio = { workspace = true, features = ["net", "io-std", "rt-multi-thread", "macros"] }
+94
View File
@@ -0,0 +1,94 @@
//! `<app-data>/mcp/server.json`: where the running IDE's server listens and
//! its token, for the stdio bridge. Readable only by the user; removed when
//! the server stops.
use serde::{Deserialize, Serialize};
use std::io;
use std::path::{Path, PathBuf};
/// The discovery file, relative to the app data directory.
pub const DISCOVERY_FILE: &str = "mcp/server.json";
/// The discovery file of the IDE whose app data directory is `app_data`.
pub fn discovery_path(app_data: &Path) -> PathBuf {
app_data.join(DISCOVERY_FILE)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discovery {
pub port: u16,
pub token: String,
/// The IDE's process.
pub pid: u32,
/// The IDE's version.
pub version: String,
}
impl Discovery {
pub fn read(path: &Path) -> io::Result<Self> {
let bytes = std::fs::read(path)?;
serde_json::from_slice(&bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
/// Writes the file atomically, readable only by the user.
pub fn write(&self, path: &Path) -> io::Result<()> {
let dir = path.parent().ok_or_else(|| io::Error::other("no parent directory"))?;
std::fs::create_dir_all(dir)?;
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
let bytes = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
let written = write_private(&tmp, &bytes).and_then(|()| std::fs::rename(&tmp, path));
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
/// Removes the file at `path` if it still describes this process's server.
pub fn remove_if_ours(path: &Path) {
if Self::read(path).is_ok_and(|d| d.pid == std::process::id()) {
let _ = std::fs::remove_file(path);
}
}
}
#[cfg(unix)]
fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let _ = std::fs::remove_file(path);
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)?;
file.write_all(bytes)?;
file.sync_all()
}
/// The app data directory is the user's own on Windows.
#[cfg(not(unix))]
fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
std::fs::write(path, bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_privately_and_only_its_owner_removes_it() {
let tmp = tempfile::tempdir().unwrap();
let path = discovery_path(tmp.path());
let ours = Discovery { port: 1234, token: "t".into(), pid: std::process::id(), version: "0.1.0".into() };
ours.write(&path).unwrap();
assert_eq!(Discovery::read(&path).unwrap(), ours);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
}
let theirs = Discovery { pid: ours.pid.wrapping_add(1), ..ours.clone() };
theirs.write(&path).unwrap();
Discovery::remove_if_ours(&path);
assert!(path.is_file(), "another process's file stays");
ours.write(&path).unwrap();
Discovery::remove_if_ours(&path);
assert!(!path.exists());
}
}
+154
View File
@@ -0,0 +1,154 @@
//! The IDE's MCP server: AI agents outside the IDE (Claude Code, Codex…) call
//! the IDE's built-in tools.
//!
//! - **Transport:** MCP Streamable HTTP (2025-06-18; 2025-03-26 and 2024-11-05
//! clients too) on `127.0.0.1:<random port>`, endpoint `/mcp`. Responses are
//! plain JSON (no SSE streams: `GET` is 405). Every request carries
//! `Authorization: Bearer <token>` (random, per run); requests from another
//! `Origin` or `Host` are refused (DNS rebinding).
//! - **Projects:** the `X-OpenIDEA-Project` header or the `?project=` query
//! names a folder; the server picks the open project containing it.
//! - **Discovery:** `<app-data>/mcp/server.json` (`port`, `token`, `pid`,
//! `version`; readable only by the user) while the server runs.
//! - **Stdio:** `<app exe> --mcp-stdio [--project <path>]` reads the discovery
//! file and relays newline-delimited JSON-RPC to the server ([`stdio`]), so
//! persisted client configurations hold no token.
//!
//! The tools themselves come from a [`ToolRegistry`] (the IDE host); this
//! crate knows nothing about plugins.
mod discovery;
mod protocol;
mod server;
pub mod stdio;
pub use discovery::{DISCOVERY_FILE, Discovery, discovery_path};
pub use server::McpServer;
pub use tokio_util::sync::CancellationToken;
use std::path::{Path, PathBuf};
/// The header naming the project a request is about (an absolute path).
pub const PROJECT_HEADER: &str = "x-openidea-project";
/// The command-line flag that runs the executable as the stdio bridge.
pub const STDIO_FLAG: &str = "--mcp-stdio";
/// A tool as `tools/list` shows it.
#[derive(Clone, Debug, PartialEq)]
pub struct Tool {
pub name: String,
pub description: String,
/// A JSON Schema of the `arguments` object.
pub input_schema: serde_json::Value,
pub output_schema: Option<serde_json::Value>,
/// Only reads (`annotations.readOnlyHint`).
pub read_only: bool,
/// May destroy data (`annotations.destructiveHint`).
pub destructive: bool,
}
/// A failed `tools/call`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ToolCallError {
/// No such tool for the project: a protocol error.
UnknownTool(String),
/// The tool ran and failed, or couldn't run: a result with `isError`.
Failed(String),
}
/// Where the tools come from (the IDE host). Calls block; the server runs
/// them off its async threads.
pub trait ToolRegistry: Send + Sync + 'static {
/// Roots of the projects open in the IDE that the server serves.
fn projects(&self) -> Vec<PathBuf>;
/// The tools callable for `project` (one of [`Self::projects`]). `Err`
/// says why there are none (an unsupported project).
fn tools(&self, project: &Path) -> Result<Vec<Tool>, String>;
fn call_with_client(
&self,
project: &Path,
name: &str,
arguments: &str,
cancel: CancellationToken,
_client: &str,
) -> Result<String, ToolCallError> {
self.call(project, name, arguments, cancel)
}
/// Runs tool `name` with `arguments` (a JSON object, as text) for `project`.
fn call(
&self,
project: &Path,
name: &str,
arguments: &str,
cancel: CancellationToken,
) -> Result<String, ToolCallError>;
}
/// The URL of the server at `port` for `project`.
pub fn endpoint_url(port: u16, project: &Path) -> String {
format!("http://127.0.0.1:{port}/mcp?project={}", encode_query(&project.to_string_lossy()))
}
/// The stdio bridge's command line for `project`: `exe --mcp-stdio --project <project>`.
pub fn stdio_command(exe: &Path, project: &Path) -> Vec<String> {
vec![
exe.to_string_lossy().into_owned(),
STDIO_FLAG.to_owned(),
"--project".to_owned(),
project.to_string_lossy().into_owned(),
]
}
/// Whether `args` (the process's, program name first) ask for the stdio
/// bridge; then the project it is for (`--project <path>`, `None`: the
/// current directory).
pub fn stdio_request(args: &[String]) -> Option<Option<PathBuf>> {
let rest = args.get(1..).unwrap_or_default();
if !rest.iter().any(|a| a == STDIO_FLAG) {
return None;
}
let project = rest
.iter()
.position(|a| a == "--project")
.and_then(|i| rest.get(i + 1))
.map(PathBuf::from)
.or_else(|| rest.iter().find_map(|a| a.strip_prefix("--project=").map(PathBuf::from)));
Some(project)
}
/// Characters escaped in a query value: everything but unreserved characters and `/`.
const QUERY: &percent_encoding::AsciiSet =
&percent_encoding::NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.').remove(b'~').remove(b'/');
pub(crate) fn encode_query(value: &str) -> String {
percent_encoding::utf8_percent_encode(value, QUERY).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urls_and_commands_name_the_project() {
let url = endpoint_url(4321, Path::new("/home/me/my app/项目"));
assert_eq!(url, "http://127.0.0.1:4321/mcp?project=/home/me/my%20app/%E9%A1%B9%E7%9B%AE");
assert_eq!(
stdio_command(Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea"), Path::new("/p")),
["/Applications/OpenIDEA.app/Contents/MacOS/openidea", "--mcp-stdio", "--project", "/p"]
);
}
#[test]
fn stdio_requests_are_recognized() {
let args = |a: &[&str]| a.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
assert_eq!(stdio_request(&args(&["openidea"])), None);
assert_eq!(stdio_request(&args(&["openidea", "/some/project"])), None);
assert_eq!(stdio_request(&args(&["openidea", "--mcp-stdio"])), Some(None));
assert_eq!(
stdio_request(&args(&["openidea", "--mcp-stdio", "--project", "/p"])),
Some(Some(PathBuf::from("/p")))
);
assert_eq!(stdio_request(&args(&["openidea", "--project=/q", "--mcp-stdio"])), Some(Some(PathBuf::from("/q"))));
}
}
+298
View File
@@ -0,0 +1,298 @@
//! MCP over JSON-RPC 2.0: `initialize`, `ping`, `tools/list`, `tools/call`
//! and notifications, for one POST body (a message or a batch). Blocking: the
//! server runs it on a blocking thread.
use crate::{CancellationToken, Tool, ToolCallError, ToolRegistry};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
#[derive(Default)]
pub(crate) struct Requests(Mutex<RequestState>);
#[derive(Default)]
struct RequestState {
clients: HashMap<String, String>,
running: HashMap<(String, String), CancellationToken>,
early: HashMap<(String, String), std::time::Instant>,
}
struct InFlight<'a> {
requests: &'a Requests,
key: (String, String),
}
impl Drop for InFlight<'_> {
fn drop(&mut self) {
self.requests.0.lock().unwrap().running.remove(&self.key);
}
}
/// The protocol version answered when the client asks for one this server doesn't know.
pub(crate) const LATEST: &str = "2025-06-18";
pub(crate) const SUPPORTED: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"];
const PARSE_ERROR: i64 = -32700;
const INVALID_REQUEST: i64 = -32600;
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
/// The requested folder isn't a project open in the IDE (or has no tools there).
pub(crate) const NOT_OPEN: i64 = -32001;
pub(crate) struct RpcError {
code: i64,
message: String,
}
impl RpcError {
fn new(code: i64, message: impl Into<String>) -> Self {
Self { code, message: message.into() }
}
}
pub(crate) fn error_response(id: Value, code: i64, message: &str) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
}
/// Answers one POST body: `Ok(None)` when it held only notifications and
/// responses (HTTP 202), `Err` with the error response when it isn't JSON.
pub(crate) fn handle_body(
registry: &dyn ToolRegistry,
project: Option<&str>,
body: &[u8],
requests: &Requests,
scope: &str,
cancel: CancellationToken,
) -> Result<Option<Value>, Value> {
let message: Value = serde_json::from_slice(body)
.map_err(|e| error_response(Value::Null, PARSE_ERROR, &format!("not JSON: {e}")))?;
let session = Session { registry, project, requests, scope, cancel };
Ok(match message {
Value::Array(batch) if batch.is_empty() => Some(error_response(Value::Null, INVALID_REQUEST, "an empty batch")),
Value::Array(batch) => {
let replies: Vec<Value> = batch.into_iter().filter_map(|m| session.handle(m)).collect();
(!replies.is_empty()).then_some(Value::Array(replies))
}
message => session.handle(message),
})
}
struct Session<'a> {
registry: &'a dyn ToolRegistry,
project: Option<&'a str>,
requests: &'a Requests,
scope: &'a str,
cancel: CancellationToken,
}
impl Session<'_> {
/// The response to one message (`None` for notifications and responses).
fn handle(&self, message: Value) -> Option<Value> {
let Value::Object(mut message) = message else {
return Some(error_response(Value::Null, INVALID_REQUEST, "not a JSON-RPC message"));
};
let id = message.remove("id");
if message.get("jsonrpc") != Some(&json!("2.0"))
|| id.as_ref().is_some_and(|id| !id.is_null() && !id.is_string() && !id.is_number())
{
return Some(error_response(Value::Null, INVALID_REQUEST, "invalid JSON-RPC envelope"));
}
let Some(method) = message.get("method").and_then(Value::as_str).map(str::to_owned) else {
// A response to a request of ours (we send none), or garbage.
return id.map(|id| error_response(id, INVALID_REQUEST, "not a JSON-RPC request"));
};
if method == "notifications/cancelled" {
if let Some(id) = message.get("params").and_then(|p| p.get("requestId")) {
let key = (self.scope.to_owned(), id.to_string());
let mut state = self.requests.0.lock().unwrap();
if let Some(cancel) = state.running.get(&key) {
cancel.cancel();
} else if state.early.len() < 1024 {
state.early.insert(key, std::time::Instant::now());
}
}
return None;
}
let id = id?;
let key = (self.scope.to_owned(), id.to_string());
let cancel = self.cancel.child_token();
{
let mut requests = self.requests.0.lock().unwrap();
requests.early.retain(|_, at| at.elapsed() < std::time::Duration::from_secs(60));
if requests.running.contains_key(&key) {
return Some(error_response(id, INVALID_REQUEST, "request id already in flight"));
}
if requests.early.remove(&key).is_some() {
cancel.cancel();
}
requests.running.insert(key.clone(), cancel.clone());
}
let _in_flight = InFlight { requests: self.requests, key };
let params = message.remove("params").unwrap_or(Value::Null);
let result = match self.request(&method, params, cancel) {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err(e) => error_response(id, e.code, &e.message),
};
Some(result)
}
fn request(&self, method: &str, params: Value, cancel: CancellationToken) -> Result<Value, RpcError> {
match method {
"initialize" => Ok(self.initialize(&params)),
"ping" => Ok(json!({})),
"tools/list" => {
let project = self.project()?;
let tools = self.registry.tools(&project).map_err(|e| RpcError::new(NOT_OPEN, e))?;
Ok(json!({ "tools": tools.iter().map(tool_json).collect::<Vec<_>>() }))
}
"tools/call" => self.call(params, cancel),
_ => Err(RpcError::new(METHOD_NOT_FOUND, format!("no method {method:?}"))),
}
}
fn initialize(&self, params: &Value) -> Value {
if let Some(name) = params.pointer("/clientInfo/name").and_then(Value::as_str) {
let mut requests = self.requests.0.lock().unwrap();
if requests.clients.len() < 1024 {
requests.clients.insert(self.scope.into(), name.chars().take(128).collect());
}
}
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(LATEST);
let version = if SUPPORTED.contains(&asked) { asked } else { LATEST };
let about = match self.project() {
Ok(root) => format!("the project open in OpenIDEA at {}", root.display()),
Err(_) => "a project open in OpenIDEA".to_owned(),
};
json!({
"protocolVersion": version,
"capabilities": { "tools": { "listChanged": false } },
"serverInfo": { "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") },
"instructions": format!(
"Built-in OpenIDEA tools for {about}: they see the files and editors the IDE has open."
),
})
}
fn call(&self, params: Value, cancel: CancellationToken) -> Result<Value, RpcError> {
let name = params
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| RpcError::new(INVALID_PARAMS, "tools/call needs a tool `name`"))?;
let arguments = match params.get("arguments") {
None | Some(Value::Null) => "{}".to_owned(),
Some(args @ Value::Object(_)) => args.to_string(),
Some(_) => return Err(RpcError::new(INVALID_PARAMS, "`arguments` must be an object")),
};
let requested = params.pointer("/arguments/projectPath");
if requested.is_some_and(|v| !v.is_string()) {
return Err(RpcError::new(INVALID_PARAMS, "projectPath must be a string"));
}
let project = self.resolve_project(requested.and_then(Value::as_str).or(self.project))?;
let outcome = if cancel.is_cancelled() {
Err(ToolCallError::Failed("cancelled".into()))
} else {
let client =
self.requests.0.lock().unwrap().clients.get(self.scope).cloned().unwrap_or_else(|| "MCP client".into());
self.registry.call_with_client(&project, name, &arguments, cancel, &client)
};
let (text, is_error) = match outcome {
Ok(text) => (text, false),
Err(ToolCallError::Failed(message)) => (message, true),
Err(ToolCallError::UnknownTool(message)) => return Err(RpcError::new(INVALID_PARAMS, message)),
};
let mut result = json!({ "content": [{ "type": "text", "text": text }], "isError": is_error });
if !is_error && let Ok(value @ Value::Object(_)) = serde_json::from_str::<Value>(&text) {
result["structuredContent"] = value;
}
Ok(result)
}
/// The open project the request names.
fn project(&self) -> Result<PathBuf, RpcError> {
self.resolve_project(self.project)
}
fn resolve_project(&self, requested: Option<&str>) -> Result<PathBuf, RpcError> {
let mut roots = self.registry.projects();
roots.sort();
roots.dedup();
if let Some(requested) = requested.filter(|p| !p.trim().is_empty()) {
return resolve(&roots, Path::new(requested)).ok_or_else(|| {
RpcError::new(
NOT_OPEN,
format!(
"this folder isn't open in OpenIDEA: {requested}. Open projects: {}",
roots.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
),
)
});
}
if roots.len() == 1 {
return Ok(roots.remove(0));
}
Err(RpcError::new(
INVALID_PARAMS,
format!(
"Specify projectPath, X-OpenIDEA-Project or ?project=. Open projects: {}",
roots.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
),
))
}
}
/// The project among `roots` containing `path` (the innermost one).
fn resolve(roots: &[PathBuf], path: &Path) -> Option<PathBuf> {
if !path.is_absolute() {
return None;
}
let path = dunce::canonicalize(path).unwrap_or_else(|_| {
let mut out = PathBuf::new();
for part in path.components() {
match part {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
part => out.push(part.as_os_str()),
}
}
out
});
roots
.iter()
.filter_map(|root| {
let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.clone());
path.starts_with(&canonical).then(|| (canonical.components().count(), root))
})
.max_by_key(|(depth, _)| *depth)
.map(|(_, root)| root.clone())
}
fn tool_json(tool: &Tool) -> Value {
let mut value = json!({
"name": tool.name, "description": tool.description, "inputSchema": tool.input_schema,
"annotations": { "readOnlyHint": tool.read_only, "destructiveHint": tool.destructive },
});
if let Some(schema) = &tool.output_schema {
value["outputSchema"] = schema.clone();
}
value
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_innermost_open_project_containing_the_path_answers() {
let tmp = tempfile::tempdir().unwrap();
let outer = dunce::canonicalize(tmp.path()).unwrap();
let inner = outer.join("inner");
std::fs::create_dir_all(inner.join("src")).unwrap();
let roots = [outer.clone(), inner.clone()];
assert_eq!(resolve(&roots, &inner.join("src")), Some(inner.clone()));
assert_eq!(resolve(&roots, &outer.join("other")), Some(outer.clone()));
assert_eq!(resolve(&roots[1..], &outer), None);
assert_eq!(resolve(&roots, Path::new("relative/path")), None);
// Siblings sharing a prefix are not inside.
assert_eq!(resolve(&roots[1..], &outer.join("inner-two")), None);
}
}
+301
View File
@@ -0,0 +1,301 @@
//! The HTTP side: one endpoint, `/mcp`, on the loopback interface.
use crate::discovery::Discovery;
use crate::{CancellationToken, PROJECT_HEADER, ToolRegistry, protocol};
use bytes::Bytes;
use http_body_util::{BodyExt, Full, Limited};
use hyper::body::Incoming;
use hyper::header::{self, HeaderMap, HeaderValue};
use hyper::{Method, Request, Response, StatusCode};
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::watch;
/// Largest request body.
const MAX_BODY: usize = 8 << 20;
struct Shared {
registry: Arc<dyn ToolRegistry>,
token: String,
port: u16,
requests: protocol::Requests,
cancel: CancellationToken,
}
/// The running server. Stopping it (or dropping it) closes the port and
/// removes its discovery file.
pub struct McpServer {
port: u16,
token: String,
discovery: Option<PathBuf>,
shutdown: watch::Sender<bool>,
cancel: CancellationToken,
}
impl McpServer {
/// Starts listening on `127.0.0.1:<random port>` with a new token, on
/// `runtime`, and writes the discovery file at `discovery` if given.
pub fn start(
registry: Arc<dyn ToolRegistry>,
discovery: Option<PathBuf>,
runtime: &tokio::runtime::Handle,
) -> std::io::Result<Self> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port();
let token = new_token()?;
let listener = {
let _entered = runtime.enter();
tokio::net::TcpListener::from_std(listener)?
};
if let Some(path) = &discovery {
let info = Discovery {
port,
token: token.clone(),
pid: std::process::id(),
version: env!("CARGO_PKG_VERSION").to_owned(),
};
info.write(path)?;
}
let (shutdown, stopped) = watch::channel(false);
let cancel = CancellationToken::new();
let shared = Arc::new(Shared {
registry,
token: token.clone(),
port,
requests: Default::default(),
cancel: cancel.clone(),
});
runtime.spawn(accept(listener, shared, stopped));
tracing::info!("MCP server on 127.0.0.1:{port}");
Ok(Self { port, token, discovery, shutdown, cancel })
}
pub fn port(&self) -> u16 {
self.port
}
/// The bearer token of this run.
pub fn token(&self) -> &str {
&self.token
}
/// The endpoint URL for `project`.
pub fn url(&self, project: &Path) -> String {
crate::endpoint_url(self.port, project)
}
pub fn stop(self) {}
}
impl Drop for McpServer {
fn drop(&mut self) {
self.cancel.cancel();
let _ = self.shutdown.send(true);
if let Some(path) = &self.discovery {
Discovery::remove_if_ours(path);
}
}
}
pub(crate) fn new_token() -> std::io::Result<String> {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(e.to_string()))?;
Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
async fn accept(listener: tokio::net::TcpListener, shared: Arc<Shared>, mut stopped: watch::Receiver<bool>) {
loop {
let stream = tokio::select! {
_ = stopped.changed() => return,
accepted = listener.accept() => match accepted {
Ok((stream, _)) => stream,
Err(e) => {
tracing::warn!("MCP server accept: {e}");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
continue;
}
},
};
let (shared, mut stopped) = (shared.clone(), stopped.clone());
tokio::spawn(async move {
let scope = match new_token() {
Ok(scope) => scope,
Err(_) => return,
};
let service = hyper::service::service_fn(move |req| serve(req, shared.clone(), scope.clone()));
let conn = hyper::server::conn::http1::Builder::new()
.serve_connection(hyper_util::rt::TokioIo::new(stream), service);
tokio::pin!(conn);
tokio::select! {
result = conn.as_mut() => {
if let Err(e) = result {
tracing::debug!("MCP connection: {e}");
}
}
_ = stopped.changed() => {}
}
});
}
}
async fn serve(
req: Request<Incoming>,
shared: Arc<Shared>,
scope: String,
) -> Result<Response<Full<Bytes>>, Infallible> {
Ok(respond(req, &shared, scope).await)
}
type Reply = Response<Full<Bytes>>;
async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) -> Reply {
if req.uri().path() != "/mcp" {
return plain(StatusCode::NOT_FOUND, "not found: the endpoint is /mcp");
}
if !local_request(req.headers(), shared.port) {
return plain(StatusCode::FORBIDDEN, "requests from web pages are refused");
}
if !authorized(req.headers(), &shared.token) {
let mut response = plain(StatusCode::UNAUTHORIZED, "missing or wrong bearer token");
response.headers_mut().insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
return response;
}
if req.method() != Method::POST {
// No server-sent event streams: every answer is in the POST's response.
let mut response = plain(StatusCode::METHOD_NOT_ALLOWED, "only POST");
response.headers_mut().insert(header::ALLOW, HeaderValue::from_static("POST"));
return response;
}
if let Some(version) = req.headers().get("mcp-protocol-version")
&& !version.to_str().is_ok_and(|v| protocol::SUPPORTED.contains(&v))
{
return plain(StatusCode::BAD_REQUEST, "unsupported MCP-Protocol-Version");
}
let scope = req.headers().get("mcp-session-id").and_then(|v| v.to_str().ok()).unwrap_or(&scope).to_owned();
let project = project_of(&req);
let body = match Limited::new(req.into_body(), MAX_BODY).collect().await {
Ok(body) => body.to_bytes(),
Err(e) => return plain(StatusCode::PAYLOAD_TOO_LARGE, &format!("request body: {e}")),
};
let initializing = serde_json::from_slice::<serde_json::Value>(&body)
.is_ok_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
let response_scope = scope.clone();
let registry = shared.registry.clone();
let shared = shared.clone();
let cancel = shared.cancel.child_token();
let _guard = cancel.clone().drop_guard();
let handled = tokio::task::spawn_blocking(move || {
protocol::handle_body(registry.as_ref(), project.as_deref(), &body, &shared.requests, &scope, cancel)
})
.await;
match handled {
Ok(Ok(Some(reply))) => {
let mut response = json(StatusCode::OK, &reply);
if initializing && let Ok(value) = HeaderValue::from_str(&response_scope) {
response.headers_mut().insert("mcp-session-id", value);
}
response
}
Ok(Ok(None)) => empty(StatusCode::ACCEPTED),
Ok(Err(parse_error)) => json(StatusCode::BAD_REQUEST, &parse_error),
Err(e) => plain(StatusCode::INTERNAL_SERVER_ERROR, &format!("the request failed: {e}")),
}
}
/// No `Origin` other than this server's own, and a loopback `Host`: a web
/// page (DNS rebinding) can't reach the tools even before the token check.
fn local_request(headers: &HeaderMap, port: u16) -> bool {
let ours = |authority: &str| {
["127.0.0.1", "localhost", "[::1]"].iter().any(|host| authority.eq_ignore_ascii_case(&format!("{host}:{port}")))
};
let host_ok = headers.get(header::HOST).is_none_or(|h| h.to_str().is_ok_and(ours));
let origin_ok = headers.get(header::ORIGIN).is_none_or(|o| {
o.to_str()
.is_ok_and(|o| o.strip_prefix("http://").is_some_and(|authority| ours(authority.trim_end_matches('/'))))
});
host_ok && origin_ok
}
fn authorized(headers: &HeaderMap, token: &str) -> bool {
let Some(given) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()) else { return false };
let Some((scheme, given)) = given.trim().split_once(' ') else { return false };
scheme.eq_ignore_ascii_case("bearer") && constant_time_eq(given.trim().as_bytes(), token.as_bytes())
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// The project the request names: the header (UTF-8), else `?project=` (percent-encoded).
fn project_of(req: &Request<Incoming>) -> Option<String> {
if let Some(value) = req.headers().get(PROJECT_HEADER) {
return String::from_utf8(value.as_bytes().to_vec()).ok();
}
req.uri().query()?.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(key == "project").then(|| {
let value = value.replace('+', " ");
percent_encoding::percent_decode_str(&value).decode_utf8_lossy().into_owned()
})
})
}
fn plain(status: StatusCode, text: &str) -> Response<Full<Bytes>> {
let mut response = Response::new(Full::new(Bytes::from(text.to_owned())));
*response.status_mut() = status;
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"));
response
}
fn json(status: StatusCode, value: &serde_json::Value) -> Response<Full<Bytes>> {
let mut response = Response::new(Full::new(Bytes::from(value.to_string())));
*response.status_mut() = status;
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
response
}
fn empty(status: StatusCode) -> Response<Full<Bytes>> {
let mut response = Response::new(Full::new(Bytes::new()));
*response.status_mut() = status;
response
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_loopback_hosts_and_origins_of_this_server_pass() {
let headers = |pairs: &[(header::HeaderName, &str)]| {
let mut h = HeaderMap::new();
for (k, v) in pairs {
h.insert(k.clone(), HeaderValue::from_str(v).unwrap());
}
h
};
assert!(local_request(&headers(&[]), 80));
assert!(local_request(&headers(&[(header::HOST, "127.0.0.1:80")]), 80));
assert!(local_request(
&headers(&[(header::HOST, "localhost:80"), (header::ORIGIN, "http://localhost:80")]),
80
));
assert!(!local_request(&headers(&[(header::HOST, "evil.example:80")]), 80));
assert!(!local_request(&headers(&[(header::HOST, "127.0.0.1:81")]), 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), 80));
}
#[test]
fn bearer_tokens_compare_exactly() {
let mut h = HeaderMap::new();
assert!(!authorized(&h, "abc"));
h.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer abc"));
assert!(authorized(&h, "abc"));
assert!(!authorized(&h, "abcd"));
h.insert(header::AUTHORIZATION, HeaderValue::from_static("Basic abc"));
assert!(!authorized(&h, "abc"));
assert_eq!(new_token().unwrap().len(), 64);
}
}
+130
View File
@@ -0,0 +1,130 @@
//! The stdio bridge: `<app exe> --mcp-stdio [--project <path>]`. MCP clients
//! that start servers as processes (and configurations that must not hold a
//! token) talk newline-delimited JSON-RPC to it; it relays each message to the
//! running IDE's server, found through the discovery file.
use crate::discovery::Discovery;
use crate::protocol::{NOT_OPEN, error_response};
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::{Request, StatusCode, header};
use serde_json::Value;
use std::path::{Path, PathBuf};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt};
/// Runs the bridge on this process's stdin and stdout until stdin closes.
/// `project` defaults to the current directory. Returns the exit code.
pub fn run(discovery: &Path, project: Option<PathBuf>) -> i32 {
let cwd = std::env::current_dir().unwrap_or_default();
let project = project.map_or_else(|| cwd.clone(), |p| if p.is_absolute() { p } else { cwd.join(p) });
let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
Ok(rt) => rt,
Err(e) => {
eprintln!("openidea --mcp-stdio: {e}");
return 1;
}
};
let stdin = tokio::io::BufReader::new(tokio::io::stdin());
match runtime.block_on(bridge(discovery, &project, stdin, tokio::io::stdout())) {
Ok(()) => 0,
Err(e) => {
eprintln!("openidea --mcp-stdio: {e}");
1
}
}
}
/// Relays each line of `input` (a JSON-RPC message or batch) to the IDE and
/// writes its answer, if any, as one line to `output`.
pub async fn bridge<R, W>(discovery: &Path, project: &Path, input: R, mut output: W) -> std::io::Result<()>
where
R: AsyncBufRead + Unpin,
W: AsyncWrite + Unpin,
{
let scope = crate::server::new_token()?;
let mut lines = input.lines();
let mut pending = tokio::task::JoinSet::new();
let mut eof = false;
loop {
let reply = tokio::select! {
line = lines.next_line(), if !eof => {
let Some(line) = line? else { eof = true; continue };
if line.trim().is_empty() { continue; }
match serde_json::from_str::<Value>(&line) {
Ok(message) => {
let (discovery, project, scope) = (discovery.to_owned(), project.to_owned(), scope.clone());
pending.spawn(async move { relay(&discovery, &project, &message, &scope).await });
continue;
}
Err(e) => Some(error_response(Value::Null, -32700, &format!("not JSON: {e}"))),
}
}
reply = pending.join_next(), if !pending.is_empty() => match reply { Some(reply) => reply.map_err(std::io::Error::other)?, None => None },
else => break,
};
if let Some(reply) = reply {
output.write_all(reply.to_string().as_bytes()).await?;
output.write_all(b"\n").await?;
output.flush().await?;
}
}
Ok(())
}
/// The IDE's answer to `message`, or an error response for each request in
/// it when the IDE can't be reached.
async fn relay(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Option<Value> {
let failure = match post(discovery, project, message, scope).await {
Ok((StatusCode::ACCEPTED, _)) => return None,
Ok((_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) {
Ok(reply) => return Some(reply),
Err(_) => String::from_utf8_lossy(&body).trim().to_owned(),
},
Ok((status, _)) => format!("OpenIDEA answered {status}"),
Err(e) => e,
};
let errors: Vec<Value> = requests(message).into_iter().map(|id| error_response(id, NOT_OPEN, &failure)).collect();
match (message, errors.len()) {
(_, 0) => None,
(Value::Array(_), _) => Some(Value::Array(errors)),
(_, _) => errors.into_iter().next(),
}
}
/// Ids of the requests in a message or batch (notifications have none).
fn requests(message: &Value) -> Vec<Value> {
let ids = |m: &Value| m.get("method").is_some().then(|| m.get("id").cloned()).flatten();
match message {
Value::Array(batch) => batch.iter().filter_map(ids).collect(),
m => ids(m).into_iter().collect(),
}
}
/// POSTs `message` to the server in the discovery file; a message for the
/// user when that fails.
async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Result<(StatusCode, Bytes), String> {
let not_running = || {
format!(
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Tools › MCP Server",
project.display()
)
};
let info = Discovery::read(discovery).map_err(|_| not_running())?;
let stream = tokio::net::TcpStream::connect(("127.0.0.1", info.port)).await.map_err(|_| not_running())?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream))
.await
.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
tokio::spawn(conn);
let request = Request::post(format!("/mcp?project={}", crate::encode_query(&project.to_string_lossy())))
.header(header::HOST, format!("127.0.0.1:{}", info.port))
.header(header::AUTHORIZATION, format!("Bearer {}", info.token))
.header("mcp-session-id", scope)
.header(header::CONTENT_TYPE, "application/json")
.header(header::ACCEPT, "application/json, text/event-stream")
.body(Full::new(Bytes::from(message.to_string())))
.map_err(|e| e.to_string())?;
let response = sender.send_request(request).await.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
let status = response.status();
let body = response.into_body().collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
Ok((status, body))
}
+350
View File
@@ -0,0 +1,350 @@
//! The server over real HTTP, and the stdio bridge in front of it, with a
//! registry standing in for the IDE host.
use ide_mcp::{Discovery, McpServer, Tool, ToolCallError, ToolRegistry, discovery_path, stdio};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
struct Registry {
project: PathBuf,
}
impl ToolRegistry for Registry {
fn projects(&self) -> Vec<PathBuf> {
vec![self.project.clone()]
}
fn tools(&self, project: &Path) -> Result<Vec<Tool>, String> {
assert_eq!(project, self.project);
Ok(vec![Tool {
name: "echo".into(),
description: "Says it back".into(),
input_schema: json!({ "type": "object", "properties": { "text": { "type": "string" } } }),
read_only: true,
destructive: false,
output_schema: None,
}])
}
fn call(
&self,
project: &Path,
name: &str,
arguments: &str,
_cancel: ide_mcp::CancellationToken,
) -> Result<String, ToolCallError> {
let args: Value = serde_json::from_str(arguments).unwrap();
match name {
"echo" if args["text"] == "fail" => Err(ToolCallError::Failed("it failed".into())),
"echo" => Ok(format!("{} in {}", args["text"].as_str().unwrap_or(""), project.display())),
_ => Err(ToolCallError::UnknownTool(format!("no tool {name:?}"))),
}
}
}
struct Setup {
_tmp: tempfile::TempDir,
project: PathBuf,
discovery: PathBuf,
server: Option<McpServer>,
}
fn setup() -> Setup {
let tmp = tempfile::tempdir().unwrap();
let project = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(project.join("src")).unwrap();
let discovery = discovery_path(&tmp.path().join("app-data"));
let registry = Arc::new(Registry { project: project.clone() });
let server = McpServer::start(registry, Some(discovery.clone()), &tokio::runtime::Handle::current()).unwrap();
Setup { _tmp: tmp, project, discovery, server: Some(server) }
}
impl Setup {
fn server(&self) -> &McpServer {
self.server.as_ref().unwrap()
}
}
/// A raw HTTP/1.1 exchange: (status, body).
async fn http(port: u16, request: &str) -> (u16, String) {
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
stream.write_all(request.as_bytes()).await.unwrap();
let mut out = String::new();
stream.read_to_string(&mut out).await.unwrap();
let status = out.split(' ').nth(1).unwrap().parse().unwrap();
let body = out.split_once("\r\n\r\n").map(|(_, b)| b.to_owned()).unwrap_or_default();
(status, body)
}
/// POSTs `body` to `path` with extra header lines; closes the connection after.
async fn post(s: &Setup, path: &str, headers: &[&str], body: &Value) -> (u16, String) {
let body = body.to_string();
let port = s.server().port();
let mut head = format!(
"POST {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n",
body.len()
);
for h in headers {
head.push_str(h);
head.push_str("\r\n");
}
http(port, &format!("{head}\r\n{body}")).await
}
fn bearer(s: &Setup) -> String {
format!("Authorization: Bearer {}", s.server().token())
}
async fn rpc(s: &Setup, method: &str, params: Value) -> Value {
let project = format!("X-OpenIDEA-Project: {}", s.project.display());
let (status, body) = post(
s,
"/mcp",
&[&bearer(s), &project],
&json!({ "jsonrpc": "2.0", "id": 7, "method": method, "params": params }),
)
.await;
assert_eq!(status, 200, "{body}");
let reply: Value = serde_json::from_str(&body).unwrap();
assert_eq!(reply["id"], 7);
reply
}
#[tokio::test(flavor = "multi_thread")]
async fn initialize_list_and_call_tools() {
let s = setup();
let init = rpc(
&s,
"initialize",
json!({ "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": { "name": "t", "version": "1" } }),
)
.await;
assert_eq!(init["result"]["protocolVersion"], "2025-03-26");
assert_eq!(init["result"]["serverInfo"]["name"], "OpenIDEA");
assert!(init["result"]["capabilities"]["tools"].is_object());
let newer = rpc(&s, "initialize", json!({ "protocolVersion": "2099-01-01" })).await;
assert_eq!(newer["result"]["protocolVersion"], "2025-06-18");
// Notifications are accepted without a body.
let (status, body) =
post(&s, "/mcp", &[&bearer(&s)], &json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })).await;
assert_eq!((status, body.as_str()), (202, ""));
let list = rpc(&s, "tools/list", json!({})).await;
let tool = &list["result"]["tools"][0];
assert_eq!(tool["name"], "echo");
assert_eq!(tool["inputSchema"]["type"], "object");
assert_eq!(tool["annotations"], json!({ "readOnlyHint": true, "destructiveHint": false }));
let call = rpc(&s, "tools/call", json!({ "name": "echo", "arguments": { "text": "hi" } })).await;
let expected = format!("hi in {}", s.project.display());
assert_eq!(call["result"], json!({ "content": [{ "type": "text", "text": expected }], "isError": false }));
let failed = rpc(&s, "tools/call", json!({ "name": "echo", "arguments": { "text": "fail" } })).await;
assert_eq!(failed["result"]["isError"], true);
assert_eq!(failed["result"]["content"][0]["text"], "it failed");
let unknown = rpc(&s, "tools/call", json!({ "name": "nope" })).await;
assert_eq!(unknown["error"]["code"], -32602);
assert_eq!(rpc(&s, "ping", json!({})).await["result"], json!({}));
assert_eq!(rpc(&s, "resources/list", json!({})).await["error"]["code"], -32601);
// The project by query, from a folder inside it; a batch.
let query = format!("/mcp?project={}", ide_mcp::endpoint_url(1, &s.project.join("src")).split_once('=').unwrap().1);
let batch = json!([
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "echo", "arguments": { "text": "a" } } },
{ "jsonrpc": "2.0", "method": "notifications/cancelled", "params": { "requestId": 9 } },
{ "jsonrpc": "2.0", "id": 2, "method": "ping" }
]);
let (status, body) = post(&s, &query, &[&bearer(&s)], &batch).await;
assert_eq!(status, 200, "{body}");
let replies: Value = serde_json::from_str(&body).unwrap();
assert_eq!(replies.as_array().unwrap().len(), 2);
assert_eq!(replies[0]["result"]["content"][0]["text"], expected.replacen("hi", "a", 1));
}
#[tokio::test(flavor = "multi_thread")]
async fn requests_without_the_token_or_from_web_pages_are_refused() {
let s = setup();
let ping = json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" });
assert_eq!(post(&s, "/mcp", &[], &ping).await.0, 401);
assert_eq!(post(&s, "/mcp", &["Authorization: Bearer wrong"], &ping).await.0, 401);
assert_eq!(post(&s, "/mcp", &[&bearer(&s), "Origin: https://evil.example"], &ping).await.0, 403);
let port = s.server().port();
let (status, _) = http(
port,
&format!(
"POST /mcp HTTP/1.1\r\nHost: evil.example:{port}\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}"
),
)
.await;
assert_eq!(status, 403, "a rebound host name");
// No SSE stream to open; other paths don't exist.
let get = format!("GET /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n{}\r\nConnection: close\r\n\r\n", bearer(&s));
assert_eq!(http(port, &get).await.0, 405);
assert_eq!(post(&s, "/other", &[&bearer(&s)], &ping).await.0, 404);
assert_eq!(post(&s, "/mcp", &[&bearer(&s), "MCP-Protocol-Version: 1999-01-01"], &ping).await.0, 400);
let (status, body) = post(&s, "/mcp", &[&bearer(&s)], &json!("{")).await;
assert_eq!(status, 200, "a JSON string is a message, just not a request: {body}");
let garbage = format!(
"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n{}\r\nContent-Length: 1\r\nConnection: close\r\n\r\n{{",
bearer(&s)
);
let (status, body) = http(port, &garbage).await;
assert_eq!(status, 400);
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["error"]["code"], -32700);
}
#[tokio::test(flavor = "multi_thread")]
async fn folders_that_are_not_open_are_named_in_the_error() {
let s = setup();
let elsewhere = s.project.parent().unwrap().join("elsewhere");
let header = format!("X-OpenIDEA-Project: {}", elsewhere.display());
let (status, body) =
post(&s, "/mcp", &[&bearer(&s), &header], &json!({"jsonrpc":"2.0","id":3,"method":"tools/list"})).await;
assert_eq!(status, 200);
let reply: Value = serde_json::from_str(&body).unwrap();
assert!(reply["error"]["message"].as_str().unwrap().contains("isn't open in OpenIDEA"));
let (_, body) = post(&s, "/mcp", &[&bearer(&s)], &json!({"jsonrpc":"2.0","id":3,"method":"tools/list"})).await;
assert!(serde_json::from_str::<Value>(&body).unwrap()["result"]["tools"].is_array());
}
#[tokio::test(flavor = "multi_thread")]
async fn the_discovery_file_lives_while_the_server_runs() {
let mut s = setup();
let info = Discovery::read(&s.discovery).unwrap();
assert_eq!((info.port, info.token.as_str(), info.pid), (s.server().port(), s.server().token(), std::process::id()));
let port = info.port;
s.server.take().unwrap().stop();
assert!(!s.discovery.exists());
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_err(), "the port is closed");
}
#[tokio::test(flavor = "multi_thread")]
async fn the_stdio_bridge_relays_lines_and_explains_a_missing_ide() {
let mut s = setup();
let input = [
json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18" } }),
json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }),
json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "echo", "arguments": { "text": "x" } } }),
]
.iter()
.map(|m| format!("{m}\n"))
.collect::<String>()
+ "\n";
let mut output = Vec::new();
stdio::bridge(&s.discovery, &s.project.join("src"), input.as_bytes(), &mut output).await.unwrap();
let mut replies: Vec<Value> =
String::from_utf8(output).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
replies.sort_by_key(|r| r["id"].as_i64());
assert_eq!(replies.len(), 2, "nothing for the notification: {replies:?}");
assert_eq!(replies[0]["result"]["protocolVersion"], "2025-06-18");
assert_eq!(replies[1]["result"]["content"][0]["text"], format!("x in {}", s.project.display()));
// The IDE quit: each request gets an error saying what to do.
s.server.take().unwrap().stop();
let mut output = Vec::new();
let input = format!("{}\n", json!({ "jsonrpc": "2.0", "id": 5, "method": "initialize", "params": {} }));
stdio::bridge(&s.discovery, &s.project, input.as_bytes(), &mut output).await.unwrap();
let reply: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(reply["id"], 5);
assert!(reply["error"]["message"].as_str().unwrap().contains("MCP server is not running"), "{reply}");
}
#[tokio::test(flavor = "multi_thread")]
async fn routing_an_argument_overrides_the_header_and_ambiguity_lists_projects() {
struct Many(Vec<PathBuf>);
impl ToolRegistry for Many {
fn projects(&self) -> Vec<PathBuf> {
self.0.clone()
}
fn tools(&self, _: &Path) -> Result<Vec<Tool>, String> {
Ok(Vec::new())
}
fn call(
&self,
project: &Path,
_: &str,
_: &str,
_: ide_mcp::CancellationToken,
) -> Result<String, ToolCallError> {
Ok(json!({"project":project}).to_string())
}
}
let mut s = setup();
s.server.take().unwrap().stop();
let second = s.project.parent().unwrap().join("second");
std::fs::create_dir_all(second.join("nested")).unwrap();
s.server = Some(
McpServer::start(
Arc::new(Many(vec![s.project.clone(), second.clone()])),
None,
&tokio::runtime::Handle::current(),
)
.unwrap(),
);
let (_, body) = post(&s, "/mcp", &[&bearer(&s)], &json!({"jsonrpc":"2.0","id":1,"method":"tools/list"})).await;
let reply: Value = serde_json::from_str(&body).unwrap();
let message = reply["error"]["message"].as_str().unwrap();
assert!(message.contains(s.project.to_str().unwrap()) && message.contains(second.to_str().unwrap()));
let result = rpc(&s, "tools/call", json!({"name":"where","arguments":{"projectPath":second.join("nested")}})).await;
assert_eq!(result["result"]["structuredContent"]["project"], json!(second));
let unknown = rpc(&s, "tools/call", json!({"name":"where","arguments":{"projectPath":"/unknown/project"}})).await;
assert_eq!(unknown["error"]["code"], -32001);
}
#[tokio::test(flavor = "multi_thread")]
async fn cancellation_reaches_a_running_tool_without_affecting_another_client() {
struct Waiting(Arc<tokio::sync::Notify>);
impl ToolRegistry for Waiting {
fn projects(&self) -> Vec<PathBuf> {
vec![PathBuf::from("/project")]
}
fn tools(&self, _: &Path) -> Result<Vec<Tool>, String> {
Ok(Vec::new())
}
fn call(
&self,
_: &Path,
_: &str,
_: &str,
cancel: ide_mcp::CancellationToken,
) -> Result<String, ToolCallError> {
self.0.notify_one();
tokio::runtime::Handle::current().block_on(cancel.cancelled());
Err(ToolCallError::Failed("cancelled".into()))
}
}
let mut s = setup();
s.server.take().unwrap().stop();
let started = Arc::new(tokio::sync::Notify::new());
s.server =
Some(McpServer::start(Arc::new(Waiting(started.clone())), None, &tokio::runtime::Handle::current()).unwrap());
let body = json!({"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"wait"}});
let auth = bearer(&s);
let headers = [&*auth, "Mcp-Session-Id: client-one"];
let call = post(&s, "/mcp", &headers, &body);
let cancel = async {
started.notified().await;
let notification = json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":11}});
assert_eq!(post(&s, "/mcp", &[&bearer(&s), "Mcp-Session-Id: client-two"], &notification).await.0, 202);
assert_eq!(post(&s, "/mcp", &[&bearer(&s), "Mcp-Session-Id: client-one"], &notification).await.0, 202);
};
let ((status, body), ()) =
tokio::time::timeout(std::time::Duration::from_secs(5), async { tokio::join!(call, cancel) }).await.unwrap();
assert_eq!(status, 200);
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["isError"], true);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_cancellation_that_arrives_before_the_call_is_remembered() {
let s = setup();
let auth = bearer(&s);
let headers = [auth.as_str(), "Mcp-Session-Id: early-cancel"];
let notification = json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":8}});
assert_eq!(post(&s, "/mcp", &headers, &notification).await.0, 202);
let call = json!({"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}});
let (_, body) = post(&s, "/mcp", &headers, &call).await;
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["isError"], true);
}
+13
View File
@@ -57,6 +57,7 @@ struct Entry {
stop: Option<StopCommand>,
output: OutputFn,
exited: AtomicBool,
code: Mutex<Option<i32>>,
stopping: AtomicBool,
}
@@ -436,6 +437,7 @@ impl ProcessManager {
stop,
output: on_output.clone(),
exited: AtomicBool::new(false),
code: Mutex::new(None),
stopping: AtomicBool::new(false),
});
self.register(entry.clone());
@@ -547,6 +549,7 @@ impl ProcessManager {
stop,
output: on_output.clone(),
exited: AtomicBool::new(false),
code: Mutex::new(None),
stopping: AtomicBool::new(false),
});
self.register(entry.clone());
@@ -685,6 +688,15 @@ impl ProcessManager {
Ok(())
}
/// Returns `None` while running, or the exit code once output has drained.
pub fn exit_code(&self, id: RunId) -> IdeResult<Option<Option<i32>>> {
let entry = self.entry(id)?;
if !entry.exited.load(Ordering::Acquire) {
return Ok(None);
}
Ok(Some(*entry.code.lock()))
}
pub fn list(&self) -> Vec<RunInfo> {
self.runs.lock().values().map(|e| e.info()).collect()
}
@@ -1078,6 +1090,7 @@ fn spawn_pump(
std::thread::sleep(Duration::from_millis(50));
}
}
*entry.code.lock() = code;
entry.exited.store(true, Ordering::Release);
tracing::debug!(id = %entry.id, ?code, "run exited");
sink.emit(IdeEvent::RunExited { run: entry.id, code });
+12
View File
@@ -253,6 +253,18 @@ pub fn validate_query(query: &SearchQuery) -> IdeResult<()> {
Ok(())
}
/// Byte ranges of the matches of `query` in `text`, matched as [`search`] does.
/// Unlike [`LineMatch`]es, a match spanning lines comes back whole.
pub fn match_ranges(text: &str, query: &SearchQuery) -> IdeResult<Vec<(usize, usize)>> {
if query.pattern.is_empty() {
return Ok(Vec::new());
}
let compiled = query::compile(query)?;
let mut out = Vec::new();
iter::for_each_match(&compiled.matcher, text.as_bytes(), 0, |m| out.push((m.start(), m.end())));
Ok(out)
}
/// Computes the replacement text for every match of `query` in `text`
/// (supports `$1` group references for regex queries).
///
+27 -3
View File
@@ -27,7 +27,10 @@ pub enum SettingsScope {
Project,
}
fn remove_custom_schemes(layer: &mut Json) {
fn remove_global_settings(layer: &mut Json) {
if let Some(layer) = layer.as_object_mut() {
layer.remove("mcpServer");
}
if let Some(editor) = layer.get_mut("editor").and_then(Json::as_object_mut) {
editor.remove("customSchemes");
}
@@ -187,7 +190,7 @@ impl SettingsStore {
for (scope, file) in self.layers() {
let Some(mut layer) = read_layer(&file) else { continue };
if scope == SettingsScope::Project {
remove_custom_schemes(&mut layer);
remove_global_settings(&mut layer);
}
if let Err(e) = try_merge(&mut merged, layer) {
tracing::warn!("{} has invalid settings, ignoring it: {e}", file.display());
@@ -220,6 +223,9 @@ impl SettingsStore {
{
return Err(IdeError::invalid("custom color schemes belong to user settings"));
}
if scope == SettingsScope::Project && (key == "mcpServer" || key.starts_with("mcpServer.")) {
return Err(IdeError::invalid("MCP server belongs to user settings"));
}
let file = self.file(scope).ok_or_else(|| IdeError::invalid("no project is open"))?.to_path_buf();
let segments: Vec<&str> = key.split('.').collect();
if segments.iter().any(|s| s.is_empty()) {
@@ -259,7 +265,7 @@ impl SettingsStore {
let layer = if is_target { parse_layer(&new_text).ok() } else { read_layer(&layer_file) };
let Some(mut layer) = layer else { continue };
if layer_scope == SettingsScope::Project {
remove_custom_schemes(&mut layer);
remove_global_settings(&mut layer);
}
if let Err(e) = try_merge(&mut merged, layer)
&& is_target
@@ -637,4 +643,22 @@ mod tests {
let s = store.set(SettingsScope::Global, "appearance.mainToolbar", Json::Null).unwrap();
assert_eq!(s.appearance.main_toolbar, None);
}
#[test]
fn mcp_server_is_default_off_and_cannot_be_enabled_by_a_project() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("config");
let project = tmp.path().join("project");
std::fs::create_dir_all(project.join(".openidea")).unwrap();
std::fs::write(project.join(".openidea/settings.toml"), "[mcpServer]\nenabled = true\n").unwrap();
let settings = SettingsStore::load(&config, Some(&project));
assert!(!settings.effective().mcp_server.enabled);
assert!(settings.set(SettingsScope::Project, "mcpServer.enabled", serde_json::json!(true)).is_err());
assert!(
settings
.set(SettingsScope::Global, "mcpServer.enabled", serde_json::json!(true))
.unwrap()
.mcp_server
.enabled
);
}
}
+1
View File
@@ -66,6 +66,7 @@ ide-spell.workspace = true
ide-ssh.workspace = true
ide-remote.workspace = true
ide-host.workspace = true
ide-mcp.workspace = true
ide-notebook.workspace = true
ide-jsonrpc.workspace = true
ide-tools = { path = "../crates/ide-tools" }
+4
View File
@@ -179,6 +179,9 @@ pub fn settings_set(
value: String,
) -> IdeResult<Settings> {
let applied = ide_host::ops::project::set_settings(&state.host, window.label(), scope, &key, &value)?;
if key == "mcpServer" || key.starts_with("mcpServer.") {
crate::commands::mcp::apply(&state);
}
// A project value concerns only that project's window (and its settings window).
let event = ide_core::IdeEvent::SettingsChanged { key };
if applied.project_scope {
@@ -231,6 +234,7 @@ pub async fn settings_import(window: Window, state: State<'_, AppState>, path: P
tauri::async_runtime::spawn(async move { lsp.reconfigure().await });
}
crate::commands::ssh::apply_ssh_settings(&state);
crate::commands::mcp::apply(&state);
state.host.apply_proxy();
state.sink.emit(ide_core::IdeEvent::SettingsChanged { key: String::new() });
let settings = ide_host::ops::project::settings(&state.host, window.label());
+150
View File
@@ -0,0 +1,150 @@
//! Tools › MCP Server: runs `ide_mcp::McpServer` over built-in host tools
//! while `mcpServer.enabled` is on, and reports where it listens.
use crate::state::AppState;
use ide_core::{IdeResult, IpcPath};
use ide_host::mcp::{FileProblems, FocusedEditor};
use parking_lot::Mutex;
use serde::Serialize;
use specta::Type;
use std::path::PathBuf;
use std::sync::Arc;
use tauri::{State, Window};
/// The server while it runs, or why it doesn't.
#[derive(Default)]
pub struct McpService {
server: Mutex<Option<ide_mcp::McpServer>>,
error: Mutex<Option<String>>,
}
/// Starts or stops the server to match the global `mcpServer.enabled`.
pub fn apply(state: &AppState) {
let enabled = state.global_settings.effective().mcp_server.enabled;
let mut server = state.mcp.server.lock();
if !enabled {
if let Some(running) = server.take() {
running.stop();
}
ide_agent::set_mcp_executable(None);
*state.mcp.error.lock() = None;
return;
}
if server.is_some() {
return;
}
let discovery = ide_mcp::discovery_path(&state.data_dir);
match ide_mcp::McpServer::start(
Arc::new(ide_host::mcp::Registry::new(state.host.clone())),
Some(discovery),
&state.host.runtime,
) {
Ok(started) => {
let exe = std::env::current_exe().unwrap_or_default();
ide_agent::set_mcp_executable(Some(exe));
*server = Some(started);
*state.mcp.error.lock() = None;
}
Err(e) => {
tracing::warn!("MCP server: {e}");
*state.mcp.error.lock() = Some(e.to_string());
}
}
}
/// Stops the server on exit (its discovery file goes with it).
pub fn stop(state: &AppState) {
ide_agent::set_mcp_executable(None);
if let Some(server) = state.mcp.server.lock().take() {
server.stop();
}
}
/// What Settings › Tools › MCP Server shows.
#[derive(Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct McpServerStatus {
pub running: bool,
/// Why it isn't running though turned on.
pub error: Option<String>,
pub port: Option<u16>,
/// The window's local project, which the URL and command below are for.
pub project: Option<IpcPath>,
/// `http://127.0.0.1:<port>/mcp?project=<root>`.
pub url: Option<String>,
/// Bearer token of `url` (changes with every run).
pub token: Option<String>,
/// The stdio bridge: this executable and its arguments.
pub stdio_command: Vec<String>,
}
#[tauri::command]
#[specta::specta]
pub fn mcp_server_status(window: Window, state: State<'_, AppState>) -> IdeResult<McpServerStatus> {
let root: Option<PathBuf> = state.project(window.label()).ok().map(|p| p.root.clone());
let server = state.mcp.server.lock();
let exe = std::env::current_exe().unwrap_or_default();
Ok(McpServerStatus {
running: server.is_some(),
error: state.mcp.error.lock().clone(),
port: server.as_ref().map(|s| s.port()),
url: server.as_ref().zip(root.as_ref()).map(|(s, r)| s.url(r)),
token: server.as_ref().map(|s| s.token().to_owned()),
stdio_command: root.as_ref().map(|r| ide_mcp::stdio_command(&exe, r)).unwrap_or_default(),
project: root.map(IpcPath::from),
})
}
#[tauri::command]
#[specta::specta]
pub fn mcp_set_open_editors(window: Window, state: State<'_, AppState>, paths: Vec<IpcPath>) -> IdeResult<()> {
state.project(window.label())?;
state.host.mcp_reports.set_open_editors(window.label(), paths.into_iter().map(PathBuf::from).collect());
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn mcp_set_focused_editor(
window: Window,
state: State<'_, AppState>,
editor: Option<FocusedEditor>,
focused: bool,
) -> IdeResult<()> {
state.project(window.label())?;
state.host.mcp_reports.set_focused_editor(window.label(), editor, focused);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn mcp_report_problems(
window: Window,
state: State<'_, AppState>,
reset: bool,
files: Vec<FileProblems>,
) -> IdeResult<()> {
state.project(window.label())?;
state.host.mcp_reports.report_problems(window.label(), reset, files);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn mcp_ui_ready(window: Window, state: State<'_, AppState>, ready: bool) -> IdeResult<()> {
state.project(window.label())?;
state.host.mcp_requests.set_ready(window.label(), ready);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn mcp_respond(
window: Window,
state: State<'_, AppState>,
id: u32,
allow: bool,
error: Option<String>,
) -> IdeResult<()> {
state.host.mcp_requests.answer(window.label(), id, allow, error)
}
+7
View File
@@ -29,6 +29,7 @@ pub mod history;
pub mod http;
pub mod java;
pub mod lsp;
pub mod mcp;
pub mod nav;
pub mod notebook;
pub mod perf;
@@ -65,6 +66,12 @@ pub(crate) fn sender<T: tauri::ipc::IpcResponse + Send + Sync + 'static>(
pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tauri_specta::Builder::<tauri::Wry>::new().commands(collect_commands![
app::app_info,
mcp::mcp_ui_ready,
mcp::mcp_respond,
mcp::mcp_server_status,
mcp::mcp_set_open_editors,
mcp::mcp_set_focused_editor,
mcp::mcp_report_problems,
android::android_devices,
android::android_logcat,
app::recent_projects,
+16 -1
View File
@@ -63,9 +63,24 @@ impl EventSink for TauriSink {
) {
flash_taskbar_unless_focused(&self.app);
}
if matches!(&event, IdeEvent::McpRequest { .. })
&& let Some(label) = self.window.clone()
{
let app = self.app.clone();
let _ = self.app.run_on_main_thread(move || {
if let Some(window) = app.get_webview_window(&label) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
});
}
let result = match &self.window {
Some(label) => {
for target in crate::windows::document_windows(&self.app, label) {
for target in crate::windows::document_windows(&self.app, label)
.into_iter()
.filter(|_| !matches!(&event, IdeEvent::McpRequest { .. } | IdeEvent::McpRequestClosed { .. }))
{
let _ = self.app.emit_to(target.as_str(), IDE_EVENT, &event);
}
self.app.emit_to(label.as_str(), IDE_EVENT, &event)
+6
View File
@@ -73,6 +73,10 @@ pub fn run() {
let context = tauri::generate_context!();
#[cfg(debug_assertions)]
let context = dev::configure(context);
if let Some(project) = ide_mcp::stdio_request(&std::env::args().collect::<Vec<_>>()) {
let app_data = dirs::data_dir().unwrap_or_default().join(&context.config().identifier);
std::process::exit(ide_mcp::stdio::run(&ide_mcp::discovery_path(&app_data), project));
}
// The `openidea` command: answer and exit, no UI.
if let Some(code) = cli::run(&context.config().identifier) {
std::process::exit(code);
@@ -190,6 +194,7 @@ pub fn run() {
let menu_state = menu::MenuState::default();
menu::install(app.handle(), &menu_state, &state.recent.list());
app.manage(state);
commands::mcp::apply(&app.state::<AppState>());
app.manage(windows);
app.manage(application_focus::ApplicationFocus::default());
#[cfg(target_os = "macos")]
@@ -268,6 +273,7 @@ pub fn run() {
if let tauri::RunEvent::Exit = event {
if let Some(state) = app.try_state::<AppState>() {
state.host.stop_processes_on_exit();
commands::mcp::stop(&state);
state.host.clear_workspace_edit_history_on_exit();
state.host.shutdown_notebooks_on_exit();
}
+2
View File
@@ -533,6 +533,7 @@ pub async fn remote_connect(
}
state.remote_dev.disconnect(&label);
state.host.close_project(&label);
state.host.mcp_reports.set_remote(&label, std::path::PathBuf::from(&connection.root));
state.remote_dev.0.lock().insert(label.clone(), connection.clone());
let weak = Arc::downgrade(&connection);
let peer = connection.peer.clone();
@@ -764,6 +765,7 @@ pub async fn remote_stop_forwarding(window: Window, state: State<'_, AppState>,
#[tauri::command]
#[specta::specta]
pub fn remote_disconnect(window: Window, state: State<'_, AppState>) {
state.host.mcp_reports.remove_remote(AppState::owner_window(window.label()));
state.remote_dev.disconnect(AppState::owner_window(window.label()));
}
+3
View File
@@ -20,6 +20,8 @@ pub struct AppState {
pub plugins: Arc<ide_host::plugins::PluginHost>,
/// The built-in web server (Open In › Browser, the editor's browser bar).
pub web: ide_web::WebServer,
/// Tools › MCP Server: built-in IDE tools for agents.
pub mcp: crate::commands::mcp::McpService,
/// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start).
pub askpass: Option<ide_ssh::askpass::AskpassServer>,
/// Remote development connections, by window label.
@@ -101,6 +103,7 @@ impl AppState {
sink: global_sink,
plugins: crate::commands::plugin::create_host(&app, &data_dir, &host)?,
web: ide_web::WebServer::new(),
mcp: Default::default(),
remote_dev: crate::remote_dev::Connections::default(),
host,
startup: Startup {
+52 -1
View File
@@ -8,6 +8,17 @@ import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core";
/** Commands */
export const commands = {
appInfo: () => __TAURI_INVOKE<AppInfo>("app_info"),
mcpUiReady: (ready: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_ui_ready", { ready })),
mcpRespond: (id: number, allow: boolean, error: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_respond", { id, allow, error })),
mcpServerStatus: () => typedError<McpServerStatus, IdeError>(__TAURI_INVOKE("mcp_server_status")),
mcpSetOpenEditors: (paths: IpcPath[]) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_set_open_editors", { paths })),
mcpSetFocusedEditor: (editor: {
path: IpcPath,
/** Zero-based lines and UTF-16 columns, end exclusive. */
selection: Range,
selectedText: string,
} | null, focused: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_set_focused_editor", { editor, focused })),
mcpReportProblems: (reset: boolean, files: FileProblems[]) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_report_problems", { reset, files })),
/** Devices `adb` knows about, online ones first. */
androidDevices: () => typedError<AndroidDevice[], IdeError>(__TAURI_INVOKE("android_devices")),
/**
@@ -3956,6 +3967,11 @@ export type FileProbe = {
tooLarge: boolean,
};
export type FileProblems = {
path: IpcPath,
items: Diagnostic[],
};
/** A path written in a document: an HTML URL attribute, a CSS `url()` / `@import`. */
export type FileReference = {
range: Range,
@@ -4024,6 +4040,13 @@ export type FilesChanged = {
removed: string[],
};
export type FocusedEditor = {
path: IpcPath,
/** Zero-based lines and UTF-16 columns, end exclusive. */
selection: Range,
selectedText: string,
};
export type FoldKind = "region" | "comment" | "imports";
export type FoldRange = {
@@ -4531,7 +4554,7 @@ export type IdeError = {
data?: ErrorData | null,
};
export type IdeEvent =
export type IdeEvent = { type: "mcpRequest"; id: number; request: McpUiRequest } | { type: "mcpRequestClosed"; id: number } |
/** Project window membership or selection changed. */
{ type: "windowGroupsChanged" } |
/** A window entered or left full screen, itself or as a member of a full-screen window group. */
@@ -5596,6 +5619,30 @@ export type MavenSettings = {
threads?: string,
};
/** Tools › MCP Server: agents use the IDE's built-in tools. */
export type McpServerSettings = {
/** Serve the tools on 127.0.0.1 while the IDE runs. */
enabled?: boolean,
};
/** What Settings › Tools › MCP Server shows. */
export type McpServerStatus = {
running: boolean,
/** Why it isn't running though turned on. */
error: string | null,
port: number | null,
/** The window's local project, which the URL and command below are for. */
project: IpcPath | null,
/** `http://127.0.0.1:<port>/mcp?project=<root>`. */
url: string | null,
/** Bearer token of `url` (changes with every run). */
token: string | null,
/** The stdio bridge: this executable and its arguments. */
stdioCommand: string[],
};
export type McpUiRequest = { type: "confirm"; client: string; tool: string; subject: string; project: string; detail: string | null } | { type: "openFile"; path: IpcPath; line: number | null; column: number | null };
export type MemoryCategory =
/** The backend and its WebView processes. */
"app" | "languageServer" |
@@ -7373,6 +7420,8 @@ export type Settings_Deserialize = {
cmake?: CMakeSettings_Deserialize,
/** Tools › Agents › Chat. */
agents?: AgentSettings,
/** Tools › MCP Server (global only). */
mcpServer?: McpServerSettings,
/** Plugin settings (`[contributes.settings]`): plugin id → its values by key. */
plugins?: { [key in string]: { [key in string]: unknown } },
};
@@ -7433,6 +7482,8 @@ export type Settings_Serialize = {
cmake: CMakeSettings_Serialize,
/** Tools › Agents › Chat. */
agents: AgentSettings,
/** Tools › MCP Server (global only). */
mcpServer: McpServerSettings,
/** Plugin settings (`[contributes.settings]`): plugin id → its values by key. */
plugins: { [key in string]: { [key in string]: unknown } },
};
+2
View File
@@ -16,6 +16,7 @@ import { HierarchyTabs, HierarchyView } from "./HierarchyView";
import { resetHierarchy, showHierarchy } from "./hierarchy";
import { installServerEvents } from "./servers";
import { ServerStatus } from "./ServerStatus";
import { reportProblems } from "./problemsReport";
import { installWorkspaceEditUndo } from "./workspaceUndo";
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
@@ -43,6 +44,7 @@ export function activate(): () => void {
const disposers = [
unsubProject,
installProblemsEvents(),
reportProblems(),
installProblemDecorations(),
installServerEvents(),
installWorkspaceEditUndo(),
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Diagnostic, FileProblems } from "@/bindings";
const reports = vi.hoisted(() => [] as [boolean, FileProblems[]][]);
vi.mock("@/platform/ipc", async (importOriginal) => ({
...(await importOriginal<typeof import("@/platform/ipc")>()),
call: (promise: Promise<unknown>) => promise,
commands: {
mcpReportProblems: async (reset: boolean, files: FileProblems[]) => {
reports.push([reset, files]);
},
},
}));
vi.mock("@/platform/project", () => ({
useProject: { getState: () => ({ project: { root: "/p" } }), subscribe: () => () => {} },
}));
const { applyDiagnosticsEvent, clearProblems } = await import("./problemsStore");
const { reportProblems } = await import("./problemsReport");
const d = (line: number): Diagnostic => ({
range: { start: { line, col: 0 }, end: { line, col: 1 } },
severity: "error",
message: `line ${line}`,
source: null,
code: null,
});
const summary = () => reports.map(([reset, files]) => [reset, files.map((f) => [f.path, f.items.length])]);
describe("reportProblems", () => {
let stop: () => void;
beforeEach(() => {
vi.useFakeTimers();
clearProblems();
reports.length = 0;
applyDiagnosticsEvent("/p/a.rs", "ra", [d(1), d(2)]);
stop = reportProblems();
});
afterEach(() => {
stop();
vi.useRealTimers();
});
it("sends everything first, then only the files that changed once diagnostics settle", async () => {
await vi.advanceTimersByTimeAsync(1000);
applyDiagnosticsEvent("/p/b.rs", "ra", [d(0)]);
applyDiagnosticsEvent("/p/a.rs", "ra", []);
await vi.advanceTimersByTimeAsync(500);
expect(reports).toHaveLength(1);
await vi.advanceTimersByTimeAsync(500);
applyDiagnosticsEvent("/p/b.rs", "clippy", [d(3)]);
await vi.advanceTimersByTimeAsync(1000);
expect(summary()).toEqual([
[true, [["/p/a.rs", 2]]],
[
false,
[
["/p/b.rs", 1],
["/p/a.rs", 0],
],
],
[false, [["/p/b.rs", 2]]],
]);
});
});
@@ -0,0 +1,51 @@
/** Reports changed files from the Problems view to the built-in MCP host. */
import { commands, call, type FileProblems } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { isRemoteWindow } from "@/platform/remote";
import { useProblems, type ProblemsMap } from "./problemsStore";
const REPORT_MS = 1000;
export function reportProblems(): () => void {
let disposed = false;
let reported: ProblemsMap = new Map();
let reset = true;
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
timer = null;
if (isRemoteWindow() || !useProject.getState().project) return;
const map = useProblems.getState().map;
const files: FileProblems[] = [];
for (const [path, sources] of map)
if (reset || reported.get(path) !== sources) files.push({ path, items: [...sources.values()].flat() });
if (!reset) for (const path of reported.keys()) if (!map.has(path)) files.push({ path, items: [] });
if (!files.length && !reset) return;
const wasReset = reset;
reported = map;
reset = false;
void call(commands.mcpReportProblems(wasReset, files)).catch(() => {
reset = true;
if (!disposed) schedule();
});
};
const schedule = () => {
if (!timer) timer = setTimeout(flush, REPORT_MS);
};
schedule();
const off = useProblems.subscribe((s, prev) => {
if (s.map !== prev.map) schedule();
});
const offProject = useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) {
reset = true;
reported = new Map();
schedule();
}
});
return () => {
disposed = true;
offProject();
off();
if (timer) clearTimeout(timer);
};
}
+2
View File
@@ -4,6 +4,7 @@
* a disposer. The editor provides the editor service; app must install its launch
* barrier before welcome can restore a remembered project.
*/
import { activate as mcp } from "./mcp";
import { activate as projectTrust } from "./project-trust";
import { activate as editor } from "@/editor/core";
import { activate as editorLsp } from "@/editor/lsp";
@@ -59,6 +60,7 @@ const features: Array<[string, Activate]> = [
["project-trust", projectTrust],
["editor", editor],
["editor-lsp", editorLsp],
["mcp", mcp],
["app", app],
["welcome", welcome],
["project-tree", projectTree],
+16
View File
@@ -0,0 +1,16 @@
.content {
display: flex;
flex-direction: column;
gap: var(--space-3);
overflow-wrap: anywhere;
}
.detail {
margin: 0;
max-height: 240px;
overflow: auto;
white-space: pre-wrap;
font-family: var(--font-mono);
font-size: var(--fs-small);
color: var(--fg-secondary);
}
+33
View File
@@ -0,0 +1,33 @@
import { useTx } from "@/platform/i18n";
import { Dialog } from "@/ui";
import { answerConfirm, useMcpConfirms } from "./requests";
import styles from "./ConfirmDialog.module.css";
export function McpConfirmHost() {
const request = useMcpConfirms((s) => s.queue[0]);
const waiting = useMcpConfirms((s) => s.queue.length - 1);
const t = useTx();
if (!request) return null;
const deny = () => answerConfirm(request, false);
return (
<Dialog
open
icon="warning"
title={t`${request.client} wants to run ${request.subject}`}
onClose={deny}
width={480}
footerStart={waiting > 0 ? <span>{t`${waiting} more waiting`}</span> : undefined}
buttons={[
{ label: "Allow", role: "extra", onClick: () => answerConfirm(request, true) },
{ label: "Deny", role: "default", autoFocus: true, onClick: deny },
]}
>
<div className={styles.content}>
<div>{t`Project: ${request.project}`}</div>
<div>{t`Tool: ${request.tool}`}</div>
{request.detail && <pre className={styles.detail}>{request.detail}</pre>}
{request.error && <div role="alert">{t`Could not send the answer: ${request.error}`}</div>}
</div>
</Dialog>
);
}
+104
View File
@@ -0,0 +1,104 @@
/** Window snapshots used by the built-in MCP tools. */
import { activeEditor, onEditorEvent, openEditorPaths, useEditorRegistry } from "@/editor/api";
import { commands, call, onIdeEvent, ideEventsReady } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { isRemoteWindow } from "@/platform/remote";
import { registerOverlay } from "@/workbench/contributions";
import { McpConfirmHost } from "./ConfirmDialog";
import { denyAll, dropConfirm, handleMcpRequest } from "./requests";
export function activate(): () => void {
const offOverlay = registerOverlay({ id: "mcp.confirm", component: McpConfirmHost });
const offRequests = onIdeEvent("mcpRequest", (e) => void handleMcpRequest(e.id, e.request));
const offClosed = onIdeEvent("mcpRequestClosed", (e) => dropConfirm(e.id));
const ready = () => {
if (!isRemoteWindow() && useProject.getState().project)
void ideEventsReady()
.then(() => call(commands.mcpUiReady(true)))
.catch(() => {});
};
const unload = () => {
denyAll();
if (!isRemoteWindow()) void call(commands.mcpUiReady(false)).catch(() => {});
};
ready();
window.addEventListener("beforeunload", unload);
let lastPaths: string | undefined;
let lastEditor: string | undefined;
let disposed = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const report = () => {
timer = null;
if (isRemoteWindow() || !useProject.getState().project) return;
const paths = openEditorPaths();
const editor = activeEditor();
const focused = document.hasFocus();
const selection = editor?.view.state.selection.main;
const doc = editor?.view.state.doc;
const start = doc && selection ? doc.lineAt(selection.from) : null;
const end = doc && selection ? doc.lineAt(selection.to) : null;
const current =
editor && selection && start && end
? {
path: editor.ctx.path,
selection: {
start: { line: start.number - 1, col: selection.from - start.from },
end: { line: end.number - 1, col: selection.to - end.from },
},
selectedText: doc!
.sliceString(selection.from, Math.min(selection.to, selection.from + 65536))
.replace(/[\uD800-\uDBFF]$/, ""),
}
: null;
const root = useProject.getState().project?.root;
const pathKey = JSON.stringify([root, paths]);
const editorKey = JSON.stringify([root, current, focused]);
if (pathKey !== lastPaths) {
lastPaths = pathKey;
void call(commands.mcpSetOpenEditors(paths)).catch(() => {
lastPaths = undefined;
if (!disposed) schedule();
});
}
if (editorKey !== lastEditor) {
lastEditor = editorKey;
void call(commands.mcpSetFocusedEditor(current, focused)).catch(() => {
lastEditor = undefined;
if (!disposed) schedule();
});
}
};
const schedule = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(report, 300);
};
schedule();
const offRegistry = useEditorRegistry.subscribe(schedule);
const offEvents = onEditorEvent(schedule);
const offProject = useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) {
denyAll();
ready();
lastPaths = undefined;
lastEditor = undefined;
schedule();
}
});
window.addEventListener("focus", schedule);
window.addEventListener("blur", schedule);
return () => {
disposed = true;
unload();
offOverlay();
offRequests();
offClosed();
window.removeEventListener("beforeunload", unload);
offRegistry();
offEvents();
offProject();
window.removeEventListener("focus", schedule);
window.removeEventListener("blur", schedule);
if (timer) clearTimeout(timer);
};
}
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
paths: ["/p/a.txt"],
remote: false,
root: "/p",
registryChanged: () => {},
editorChanged: () => {},
projectChanged: () => {},
reportOpen: vi.fn(async () => undefined),
reportFocused: vi.fn(async () => undefined),
}));
vi.mock("@/editor/api", () => ({
openEditorPaths: () => state.paths,
activeEditor: () => ({
ctx: { path: "/p/a.txt" },
view: {
state: {
selection: { main: { from: 3, to: 5 } },
doc: {
lineAt: () => ({ number: 1, from: 0 }),
sliceString: () => "hi",
},
},
},
}),
useEditorRegistry: {
subscribe: (fn: () => void) => {
state.registryChanged = fn;
return () => {};
},
},
onEditorEvent: (fn: () => void) => {
state.editorChanged = fn;
return () => {};
},
}));
vi.mock("@/platform/project", () => ({
useProject: {
getState: () => ({ project: { root: state.root } }),
subscribe: (fn: (s: unknown, prev: unknown) => void) => {
state.projectChanged = () => fn({ project: { root: state.root } }, { project: null });
return () => {};
},
},
}));
vi.mock("@/platform/remote", () => ({ isRemoteWindow: () => state.remote }));
vi.mock("@/platform/ipc", () => ({
call: (promise: Promise<unknown>) => promise,
onIdeEvent: () => () => {},
ideEventsReady: async () => {},
commands: {
mcpUiReady: vi.fn(async () => {}),
mcpSetOpenEditors: state.reportOpen,
mcpSetFocusedEditor: state.reportFocused,
},
}));
vi.mock("@/workbench/contributions", () => ({ registerOverlay: () => () => {}, editorService: vi.fn() }));
vi.mock("./ConfirmDialog", () => ({ McpConfirmHost: () => null }));
const { activate } = await import("./index");
describe("MCP editor reports", () => {
let stop: () => void;
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
state.paths = ["/p/a.txt"];
state.remote = false;
state.root = "/p";
vi.spyOn(document, "hasFocus").mockReturnValue(true);
stop = activate();
});
afterEach(() => {
stop();
vi.restoreAllMocks();
vi.useRealTimers();
});
it("debounces editor tabs and sends only changes", async () => {
await vi.advanceTimersByTimeAsync(200);
state.paths = ["/p/a.txt", "/p/b.txt"];
state.registryChanged();
await vi.advanceTimersByTimeAsync(299);
expect(state.reportOpen).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(state.reportOpen).toHaveBeenCalledWith(["/p/a.txt", "/p/b.txt"]);
expect(state.reportFocused).toHaveBeenCalledWith(
{
path: "/p/a.txt",
selection: {
start: { line: 0, col: 3 },
end: { line: 0, col: 5 },
},
selectedText: "hi",
},
true,
);
state.editorChanged();
await vi.advanceTimersByTimeAsync(300);
expect(state.reportOpen).toHaveBeenCalledTimes(1);
expect(state.reportFocused).toHaveBeenCalledTimes(1);
});
it("reports again after reopening the same project", async () => {
await vi.advanceTimersByTimeAsync(300);
state.projectChanged();
await vi.advanceTimersByTimeAsync(300);
expect(state.reportOpen).toHaveBeenCalledTimes(2);
expect(state.reportFocused).toHaveBeenCalledTimes(2);
});
it("does not report remote windows or after disposal", async () => {
state.remote = true;
await vi.advanceTimersByTimeAsync(300);
expect(state.reportOpen).not.toHaveBeenCalled();
state.remote = false;
state.registryChanged();
stop();
await vi.advanceTimersByTimeAsync(300);
expect(state.reportOpen).not.toHaveBeenCalled();
});
});
+78
View File
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
const mocks = vi.hoisted(() => ({ respond: vi.fn(async () => ({ status: "ok", data: null })), open: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<object>()),
commands: { mcpRespond: mocks.respond },
}));
vi.mock("@/workbench/contributions", () => ({ editorService: () => ({ openFile: mocks.open }) }));
import { McpConfirmHost } from "./ConfirmDialog";
import { answerConfirm, denyAll, dropConfirm, handleMcpRequest, useMcpConfirms } from "./requests";
const confirm = {
type: "confirm",
client: "test client",
tool: "execute_run_configuration",
subject: "Tests",
project: "/project",
detail: "npm test",
} as const;
beforeEach(() => {
vi.clearAllMocks();
useMcpConfirms.setState({ queue: [] });
});
afterEach(() => {
cleanup();
denyAll();
});
describe("MCP confirmations", () => {
it("queues requests, allows one, and denies the next on Escape", async () => {
await handleMcpRequest(1, confirm);
await handleMcpRequest(2, { ...confirm, subject: "Build" });
render(<McpConfirmHost />);
expect(screen.getByText("npm test")).toBeTruthy();
expect(screen.getByText("test client wants to run Tests")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Allow" }));
await waitFor(() => expect(mocks.respond).toHaveBeenCalledWith(1, true, null));
expect(screen.getByText("test client wants to run Build")).toBeTruthy();
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => expect(mocks.respond).toHaveBeenCalledWith(2, false, null));
expect(useMcpConfirms.getState().queue).toEqual([]);
});
it("denies by default on Enter and denies everything on unload", async () => {
await handleMcpRequest(3, confirm);
render(<McpConfirmHost />);
fireEvent.keyDown(document, { key: "Enter" });
await waitFor(() => expect(mocks.respond).toHaveBeenCalledWith(3, false, null));
await act(async () => {
await handleMcpRequest(4, confirm);
await handleMcpRequest(5, confirm);
denyAll();
});
expect(mocks.respond).toHaveBeenCalledWith(4, false, null);
expect(mocks.respond).toHaveBeenCalledWith(5, false, null);
});
it("drops expired requests, including failed answers that arrive later", async () => {
await handleMcpRequest(6, confirm);
const request = useMcpConfirms.getState().queue[0]!;
let reject!: (e: Error) => void;
const sending = new Promise<void>((_, no) => {
reject = no;
});
answerConfirm(request, true, () => sending);
dropConfirm(6);
reject(new Error("expired"));
await Promise.resolve();
expect(useMcpConfirms.getState().queue).toEqual([]);
});
it("opens a file with zero-based UTF-16 coordinates and reports errors", async () => {
await handleMcpRequest(7, { type: "openFile", path: "/project/a.ts", line: 2, column: 4 });
expect(mocks.open).toHaveBeenCalledWith("/project/a.ts", { selection: { line: 1, col: 3 }, focus: true });
expect(mocks.respond).toHaveBeenCalledWith(7, true, null);
mocks.open.mockRejectedValueOnce(new Error("cannot open"));
await handleMcpRequest(8, { type: "openFile", path: "/project/b.ts", line: null, column: null });
expect(mocks.respond).toHaveBeenCalledWith(8, false, "Error: cannot open");
});
});
+55
View File
@@ -0,0 +1,55 @@
/** Window requests from the built-in MCP server, answered through window-scoped IPC. */
import { create } from "zustand";
import { call, commands, type McpUiRequest } from "@/platform/ipc";
import { editorService } from "@/workbench/contributions";
type Confirm = Extract<McpUiRequest, { type: "confirm" }> & { id: number; error?: string };
export const useMcpConfirms = create<{ queue: Confirm[] }>(() => ({ queue: [] }));
export type Respond = (id: number, allow: boolean, error?: string) => Promise<void>;
const respond: Respond = async (id, allow, error) => {
await call(commands.mcpRespond(id, allow, error ?? null));
};
const sending = new Set<number>();
export function dropConfirm(id: number) {
sending.delete(id);
useMcpConfirms.setState((s) => ({ queue: s.queue.filter((c) => c.id !== id) }));
}
export function answerConfirm(c: Confirm, allow: boolean, send: Respond = respond) {
dropConfirm(c.id);
sending.add(c.id);
void send(c.id, allow).then(
() => sending.delete(c.id),
(error: unknown) => {
if (!sending.delete(c.id)) return;
useMcpConfirms.setState((s) => ({ queue: [{ ...c, error: String(error) }, ...s.queue] }));
},
);
}
export function denyAll(send: Respond = respond) {
const queue = useMcpConfirms.getState().queue;
useMcpConfirms.setState({ queue: [] });
sending.clear();
for (const c of queue) void send(c.id, false).catch(() => {});
}
export async function handleMcpRequest(
id: number,
request: McpUiRequest,
send: Respond = respond,
editor = editorService,
) {
if (request.type === "confirm") {
useMcpConfirms.setState((s) =>
s.queue.some((c) => c.id === id) ? s : { queue: [...s.queue, { ...request, id }] },
);
return;
}
try {
await editor().openFile(request.path, {
selection: request.line != null ? { line: request.line - 1, col: (request.column ?? 1) - 1 } : undefined,
focus: true,
});
await send(id, true);
} catch (error) {
await send(id, false, String(error)).catch(() => {});
}
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import type { McpServerStatus } from "@/bindings";
import { DEFAULT_SETTINGS, validateSettings } from "../model/schema";
import { httpConfig, stdioConfig, MCP_SERVER_PAGE } from "../pages/mcpServer";
const status: McpServerStatus = {
running: true,
error: null,
port: 1234,
project: "/p",
url: "http://127.0.0.1:1234/mcp?project=/p",
token: "secret",
stdioCommand: ["/Applications/OpenIDEA.app/Contents/MacOS/openidea", "--mcp-stdio", "--project", "/p"],
};
describe("MCP server settings", () => {
it("defaults to off and only offers global scope", () => {
expect(DEFAULT_SETTINGS.mcpServer.enabled).toBe(false);
expect(validateSettings({}).mcpServer.enabled).toBe(false);
expect(validateSettings({ mcpServer: { enabled: true } }).mcpServer.enabled).toBe(true);
expect(MCP_SERVER_PAGE.projectScope).not.toBe(true);
});
it("copies a reusable stdio configuration without the token", () => {
const config = stdioConfig(status)!;
expect(JSON.parse(config).mcpServers.openidea).toEqual({
type: "stdio",
command: status.stdioCommand[0],
args: status.stdioCommand.slice(1),
});
expect(config).not.toContain("secret");
expect(stdioConfig({ ...status, stdioCommand: [] })).toBeNull();
});
it("copies the current HTTP endpoint and bearer header", () => {
expect(JSON.parse(httpConfig(status)!).mcpServers.openidea).toEqual({
type: "http",
url: status.url,
headers: { Authorization: "Bearer secret" },
});
expect(httpConfig({ ...status, url: null, token: null })).toBeNull();
});
});
+4
View File
@@ -96,6 +96,8 @@ export interface ValidSettings extends WorkflowSettings {
languageServers: Record<string, LanguageServerEntry>;
/** Tools › Agents › Chat: the meters in an agent task's header. */
agents: Required<AgentSettings>;
/** Tools › MCP Server (global). */
mcpServer: { enabled: boolean };
}
/** One `languageServers.<name>` entry; every field optional, like in settings.toml. */
@@ -164,6 +166,7 @@ export const DEFAULT_SETTINGS: ValidSettings = {
tools: {},
languageServers: {},
agents: { showContextUsage: true, showWorkTime: true, showUsageLimits: true, showCost: false },
mcpServer: { enabled: false },
...DEFAULT_WORKFLOW,
};
@@ -283,6 +286,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
showUsageLimits: bool(agents.showUsageLimits, d.agents.showUsageLimits),
showCost: bool(agents.showCost, d.agents.showCost),
},
mcpServer: { enabled: bool(obj(root.mcpServer).enabled, d.mcpServer.enabled) },
...validateWorkflow(root),
};
}
@@ -0,0 +1,14 @@
.status {
color: var(--fg-subtle);
}
.error {
color: var(--danger);
}
.endpoint {
width: 100%;
}
.buttons {
display: flex;
align-items: center;
gap: var(--space-2);
}
+140
View File
@@ -0,0 +1,140 @@
/** Built-in MCP server status and client configurations. */
import { useEffect, useState } from "react";
import { commands, call, isTauri, onIdeEvent, type McpServerStatus } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { useTx } from "@/platform/i18n";
import { Button, Input, T, copyText } from "@/ui";
import { Row } from "../components/Form";
import type { PageSpec } from "./spec";
import styles from "./McpServer.module.css";
/** `mcpServers` entry of an MCP client configuration (Claude Code, Cursor…). */
export function httpConfig(status: McpServerStatus): string | null {
if (!status.url || !status.token) return null;
const server = { type: "http", url: status.url, headers: { Authorization: `Bearer ${status.token}` } };
return JSON.stringify({ mcpServers: { openidea: server } }, null, 2);
}
/** The same through the stdio bridge: no token, works for as long as OpenIDEA runs. */
export function stdioConfig(status: McpServerStatus): string | null {
const [command, ...args] = status.stdioCommand;
if (!command) return null;
return JSON.stringify({ mcpServers: { openidea: { type: "stdio", command, args } } }, null, 2);
}
function useStatus(): McpServerStatus | null {
const root = useProject((s) => s.project?.root);
const [status, setStatus] = useState<McpServerStatus | null>(null);
useEffect(() => {
if (!isTauri()) return;
let live = true;
const load = () =>
void call(commands.mcpServerStatus()).then(
(s) => live && setStatus(s),
() => undefined,
);
load();
// Turning the server on or off takes effect on Apply.
const off = onIdeEvent("settingsChanged", (e) => {
if (!e.key || e.key.startsWith("mcpServer")) load();
});
return () => {
live = false;
off();
};
}, [root]);
return status;
}
function McpServerEndpoint() {
const tx = useTx();
const status = useStatus();
const [copied, setCopied] = useState<string | null>(null);
useEffect(() => {
if (!copied) return;
const timer = setTimeout(() => setCopied(null), 2000);
return () => clearTimeout(timer);
}, [copied]);
if (!status) return null;
const copy = (which: string, text: string | null) => {
if (!text) return;
void copyText(text).then(() => setCopied(which));
};
const { port, error } = status;
const state = status.running
? tx`Running on port ${port}`
: error
? tx`Failed to start: ${error}`
: tx("Not running");
const http = httpConfig(status);
const stdio = stdioConfig(status);
return (
<>
<Row label="Status">
<span className={error ? styles.error : styles.status}>{state}</span>
</Row>
<Row
label="Endpoint"
hint={
status.project
? "For this window's project. Agents name the project with the X-OpenIDEA-Project header or the ?project= query."
: "Open a project to get its endpoint."
}
>
<Input
mono
readOnly
className={styles.endpoint}
value={status.url ?? ""}
placeholder={status.running ? tx("No project open") : tx("Not running")}
aria-label="Endpoint"
/>
</Row>
<Row
label="Client configuration"
stacked
hint="The HTTP configuration holds this run's token and stops working when OpenIDEA restarts. The stdio one starts OpenIDEA's executable, which relays to the running IDE."
>
<div className={styles.buttons}>
<Button size="small" disabled={!http} onClick={() => copy("http", http)}>
<T>Copy HTTP Config</T>
</Button>
<Button size="small" disabled={!stdio} onClick={() => copy("stdio", stdio)}>
<T>Copy Stdio Config</T>
</Button>
{copied && (
<span className={styles.status} role="status">
<T>Copied to the clipboard</T>
</span>
)}
</div>
</Row>
</>
);
}
export const MCP_SERVER_PAGE: PageSpec = {
id: "tools.mcpServer",
title: "MCP Server",
group: "Tools",
order: 604,
keywords: ["mcp", "model context protocol", "ai", "agent", "claude", "codex", "tools", "stdio"],
sections: [
{
options: [
{
kind: "toggle",
key: "mcpServer.enabled",
label: "Enable MCP server",
hint: "Agents can read, edit, and run projects open in OpenIDEA through its built-in tools. Run tools ask for confirmation. The server listens on 127.0.0.1 only and requires a token.",
},
{
kind: "custom",
label: "Endpoint",
keywords: ["Copy HTTP Config", "Copy Stdio Config", "Client configuration", "Status"],
component: McpServerEndpoint,
},
],
},
],
};
+2
View File
@@ -19,6 +19,7 @@ import { Button, Checkbox, IconButton, Input, Menu, type SelectOption, T } from
import { NumberInput, Row, SearchLabel, Section, Select } from "../components/Form";
import { useLabelMatch, useSetting } from "../components/context";
import { selectPage, setQuery } from "../model/staging";
import { MCP_SERVER_PAGE } from "./mcpServer";
import type { PageSpec } from "./spec";
import { BoundToggle, Grid, Indented, PageLink, TableEditor } from "./workflowControls";
import styles from "./Workflow.module.css";
@@ -682,4 +683,5 @@ export const TOOLS_PAGES: PageSpec[] = [
},
],
},
MCP_SERVER_PAGE,
];
+24 -1
View File
@@ -461,5 +461,28 @@
"Branch '{name}' is checked out in the worktree at {path}.\nDelete the worktree and the branch? Its folder is deleted.": "Branch '{name}' is checked out in the worktree at {path}.\nDelete the worktree and the branch? Its folder is deleted.",
"Starting…": "Starting…",
"Maven Goals": "Maven Goals",
"Gradle Tasks": "Gradle Tasks"
"Gradle Tasks": "Gradle Tasks",
"MCP Server": "MCP Server",
"Enable MCP server": "Enable MCP server",
"Status": "Status",
"Running on port {port}": "Running on port {port}",
"Failed to start: {error}": "Failed to start: {error}",
"Not running": "Not running",
"Endpoint": "Endpoint",
"For this window's project. Agents name the project with the X-OpenIDEA-Project header or the ?project= query.": "For this window's project. Agents name the project with the X-OpenIDEA-Project header or the ?project= query.",
"Open a project to get its endpoint.": "Open a project to get its endpoint.",
"No project open": "No project open",
"Client configuration": "Client configuration",
"The HTTP configuration holds this run's token and stops working when OpenIDEA restarts. The stdio one starts OpenIDEA's executable, which relays to the running IDE.": "The HTTP configuration holds this run's token and stops working when OpenIDEA restarts. The stdio one starts OpenIDEA's executable, which relays to the running IDE.",
"Copy HTTP Config": "Copy HTTP Config",
"Copy Stdio Config": "Copy Stdio Config",
"Copied to the clipboard": "Copied to the clipboard",
"{0} wants to run {1}": "{0} wants to run {1}",
"{0} more waiting": "{0} more waiting",
"Project: {0}": "Project: {0}",
"Tool: {0}": "Tool: {0}",
"Could not send the answer: {0}": "Could not send the answer: {0}",
"Allow": "Allow",
"Deny": "Deny",
"Agents can read, edit, and run projects open in OpenIDEA through its built-in tools. Run tools ask for confirmation. The server listens on 127.0.0.1 only and requires a token.": "Agents can read, edit, and run projects open in OpenIDEA through its built-in tools. Run tools ask for confirmation. The server listens on 127.0.0.1 only and requires a token."
}
+22 -1
View File
@@ -6806,5 +6806,26 @@
"Always declined": "已设为始终拒绝",
"Branch '{name}' is checked out in the worktree at {path}.\nDelete the worktree and the branch? Its folder is deleted.": "分支 '{name}' 被位于 {path} 的工作树签出。\n删除该工作树和分支?其文件夹将被删除。",
"Maven Goals": "Maven 目标",
"Gradle Tasks": "Gradle 任务"
"Gradle Tasks": "Gradle 任务",
"MCP Server": "MCP 服务器",
"Enable MCP server": "启用 MCP 服务器",
"Running on port {port}": "正在端口 {port} 上运行",
"Failed to start: {error}": "启动失败:{error}",
"Not running": "未运行",
"Endpoint": "端点",
"For this window's project. Agents name the project with the X-OpenIDEA-Project header or the ?project= query.": "用于此窗口的项目。智能体通过 X-OpenIDEA-Project 请求头或 ?project= 查询参数指定项目。",
"Open a project to get its endpoint.": "打开一个项目以获取其端点。",
"No project open": "未打开项目",
"Client configuration": "客户端配置",
"The HTTP configuration holds this run's token and stops working when OpenIDEA restarts. The stdio one starts OpenIDEA's executable, which relays to the running IDE.": "HTTP 配置包含本次运行的令牌,OpenIDEA 重启后即失效。stdio 配置会启动 OpenIDEA 的可执行文件,由它转发到正在运行的 IDE。",
"Copy HTTP Config": "复制 HTTP 配置",
"Copy Stdio Config": "复制 Stdio 配置",
"Copied to the clipboard": "已复制到剪贴板",
"{0} wants to run {1}": "{0} 想要运行 {1}",
"{0} more waiting": "还有 {0} 项等待中",
"Project: {0}": "项目:{0}",
"Tool: {0}": "工具:{0}",
"Could not send the answer: {0}": "无法发送回复:{0}",
"Deny": "拒绝",
"Agents can read, edit, and run projects open in OpenIDEA through its built-in tools. Run tools ask for confirmation. The server listens on 127.0.0.1 only and requires a token.": "智能体可通过内置工具读取、编辑和运行 OpenIDEA 中打开的项目。运行工具会请求确认。服务器仅监听 127.0.0.1,并需要令牌。"
}