mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:08:16 -10:00
Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29b2b1a272 | ||
|
|
1a06f6e9cd | ||
|
|
6f9279e753 | ||
|
|
75607c0bbf | ||
|
|
c133d2dba9 | ||
|
|
0648856359 | ||
|
|
32f5ef20a0 | ||
|
|
25cd480c30 | ||
|
|
8427e48975 | ||
|
|
4139cb64c2 | ||
|
|
a73e3c21ad | ||
|
|
6d8e41c2c1 | ||
|
|
cad33ef119 | ||
|
|
5c3feea2a8 | ||
|
|
80d438098c | ||
|
|
68782a7220 | ||
|
|
cb6bcd2fa2 | ||
|
|
a243c255fe | ||
|
|
92b5d2dafd | ||
|
|
6bf1794ed6 | ||
|
|
152b71f5e0 | ||
|
|
db16214306 | ||
|
|
e7287394d1 | ||
|
|
bb86e626a2 | ||
|
|
f04164d7ee | ||
|
|
fff2c35181 | ||
|
|
e8cf744463 | ||
|
|
a601a51b94 | ||
|
|
24bee8eef2 | ||
|
|
c20f4c0e84 | ||
|
|
99100e48bd | ||
|
|
c1e5db9511 | ||
|
|
4d5de201c4 | ||
|
|
a22c8decfc | ||
|
|
dc8efedf75 | ||
|
|
fd717d2305 | ||
|
|
d5e7747803 | ||
|
|
9a4ef162bb | ||
|
|
278936dd7d | ||
|
|
49a113c875 | ||
|
|
2d6394f203 | ||
|
|
b57fba8b43 | ||
|
|
6394f5904a | ||
|
|
7f62011a08 | ||
|
|
5ea9a2a3a9 | ||
|
|
0bfd4fa3cc | ||
|
|
25424465c0 | ||
|
|
cbe4355b8d | ||
|
|
47d547e206 | ||
|
|
f42d10d86c | ||
|
|
86f390f2a9 | ||
|
|
78784837d1 | ||
|
|
4d968a2aca | ||
|
|
f6e942dd00 | ||
|
|
75cb45ed94 | ||
|
|
f07627ada6 | ||
|
|
48c5ba51b6 | ||
|
|
558ee395dc | ||
|
|
63304ec9fe | ||
|
|
65f3660bd9 | ||
|
|
105e730555 | ||
|
|
3a0a95ac90 | ||
|
|
1439adc00c | ||
|
|
0b08cde4ba | ||
|
|
cde358888c | ||
|
|
48393b2c8c | ||
|
|
587819d9ca | ||
|
|
86ed4d6c59 | ||
|
|
11911a12c6 |
No files matched your search
Generated
+83
-37
@@ -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",
|
||||
@@ -4106,12 +4113,13 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-mcp",
|
||||
"ide-tools",
|
||||
"ide-vcs",
|
||||
"ide-vfs",
|
||||
@@ -4129,7 +4137,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4143,7 +4151,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4154,7 +4162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
@@ -4168,9 +4176,23 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"winreg 0.56.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4179,7 +4201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4196,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4210,7 +4232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4231,7 +4253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4258,7 +4280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4271,7 +4293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4291,7 +4313,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4303,7 +4325,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4316,7 +4338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4324,6 +4346,7 @@ dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
"getrandom 0.3.4",
|
||||
"globset",
|
||||
"ide-agent",
|
||||
@@ -4340,6 +4363,7 @@ dependencies = [
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-mcp",
|
||||
"ide-notebook",
|
||||
"ide-plugin",
|
||||
"ide-remote",
|
||||
@@ -4358,6 +4382,7 @@ dependencies = [
|
||||
"ignore",
|
||||
"parking_lot",
|
||||
"quick-xml 0.42.0",
|
||||
"regex-lite",
|
||||
"regex-syntax",
|
||||
"reqwest",
|
||||
"rustix",
|
||||
@@ -4378,7 +4403,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4396,7 +4421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4418,7 +4443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4435,7 +4460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4491,7 +4516,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4515,9 +4540,28 @@ 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.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4540,7 +4584,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ide-core",
|
||||
@@ -4561,7 +4605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4574,7 +4618,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4592,7 +4636,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4616,7 +4660,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4635,7 +4679,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4655,7 +4699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4668,7 +4712,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4689,7 +4733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4704,7 +4748,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4717,7 +4761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4730,7 +4774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4751,7 +4795,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4772,7 +4816,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4796,7 +4840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4812,7 +4856,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -6358,7 +6402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -6370,6 +6414,7 @@ dependencies = [
|
||||
"ide-analysis",
|
||||
"ide-android",
|
||||
"ide-build",
|
||||
"ide-cli",
|
||||
"ide-clipboard",
|
||||
"ide-core",
|
||||
"ide-coverage",
|
||||
@@ -6385,6 +6430,7 @@ dependencies = [
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-mcp",
|
||||
"ide-notebook",
|
||||
"ide-plugin",
|
||||
"ide-profiler",
|
||||
|
||||
+3
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -42,9 +42,11 @@ 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" }
|
||||
ide-cli = { path = "crates/ide-cli" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -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"
|
||||
@@ -27,6 +27,29 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// ACP's error code for a request that needs the user to sign in.
|
||||
pub(crate) const AUTH_REQUIRED: i64 = -32000;
|
||||
|
||||
// Covers signed-out users and refused accounts (Claude Code: "API Error: 403").
|
||||
pub(crate) fn auth_required(code: i64, message: &str) -> bool {
|
||||
if code == AUTH_REQUIRED {
|
||||
return true;
|
||||
}
|
||||
let lower = message.to_lowercase();
|
||||
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
|
||||
words.iter().any(|w| {
|
||||
*w == "auth"
|
||||
|| w.starts_with("authenticat")
|
||||
|| w.starts_with("authoriz")
|
||||
|| w.starts_with("unauthenticat")
|
||||
|| w.starts_with("unauthoriz")
|
||||
|| w.starts_with("oauth")
|
||||
}) || words.windows(3).any(|w| w == ["not", "logged", "in"])
|
||||
|| words
|
||||
.windows(2)
|
||||
.any(|w| w == ["logged", "out"] || w == ["sign", "in"] || w == ["log", "in"] || w == ["login", "required"])
|
||||
}
|
||||
|
||||
/// The capabilities this client offers, in v1 form.
|
||||
fn client_capabilities() -> Value {
|
||||
json!({
|
||||
@@ -69,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`.
|
||||
@@ -97,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": {} });
|
||||
|
||||
@@ -16,6 +16,42 @@ const CANCEL: &str = include_str!("../../tests/fixtures/acp-v2/cancel.jsonl");
|
||||
const RESUME: &str = include_str!("../../tests/fixtures/acp-v2/resume.jsonl");
|
||||
const INITIALIZE_V1: &str = include_str!("../../tests/fixtures/acp-v2/initialize-v1.jsonl");
|
||||
|
||||
#[test]
|
||||
fn auth_required_matches_authentication_words_and_sign_in_phrases() {
|
||||
for (code, message, expected) in [
|
||||
(AUTH_REQUIRED, "This account is not eligible.", true),
|
||||
(codes::INTERNAL_ERROR, "Auth required", true),
|
||||
(codes::INTERNAL_ERROR, "Authentication failed", true),
|
||||
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 401", true),
|
||||
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 403", true),
|
||||
(codes::INTERNAL_ERROR, "Not authenticated", true),
|
||||
(codes::INTERNAL_ERROR, "Authorization failed", true),
|
||||
(codes::INTERNAL_ERROR, "Authorize your account", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthenticated", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthentication error", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthorized", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthorization error", true),
|
||||
(codes::INTERNAL_ERROR, "OAuth token has expired", true),
|
||||
(codes::INTERNAL_ERROR, "OAuth2 token has expired", true),
|
||||
(codes::INTERNAL_ERROR, "You are not logged in", true),
|
||||
(codes::INTERNAL_ERROR, "You are logged out", true),
|
||||
(codes::INTERNAL_ERROR, "Please sign in", true),
|
||||
(codes::INTERNAL_ERROR, "Please log in", true),
|
||||
(codes::INTERNAL_ERROR, "Login required", true),
|
||||
(codes::INTERNAL_ERROR, "author", false),
|
||||
(codes::INTERNAL_ERROR, "authors", false),
|
||||
(codes::INTERNAL_ERROR, "authority", false),
|
||||
(codes::INTERNAL_ERROR, "authored", false),
|
||||
(codes::INTERNAL_ERROR, "reauthenticate", false),
|
||||
(codes::INTERNAL_ERROR, "preauthorized", false),
|
||||
(codes::INTERNAL_ERROR, "signal input failed", false),
|
||||
(codes::INTERNAL_ERROR, "catalog index failed", false),
|
||||
(codes::INTERNAL_ERROR, "login succeeded", false),
|
||||
] {
|
||||
assert_eq!(auth_required(code, message), expected, "code={code}, message={message:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The messages of a fixture going `dir` (`in` or `out`).
|
||||
pub(crate) fn frames(fixture: &str, dir: &str) -> Vec<Value> {
|
||||
fixture
|
||||
@@ -527,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()
|
||||
);
|
||||
}
|
||||
+106
-13
@@ -2,7 +2,8 @@
|
||||
//! the agent reports them over ACP.
|
||||
|
||||
use crate::model::{
|
||||
AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit, AgentUsage,
|
||||
AgentCommand, AgentCommandSource, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit,
|
||||
AgentUsage,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -35,20 +36,32 @@ pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOpt
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) -> bool {
|
||||
provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("bypass"))
|
||||
/// Whether the current settings approve tool requests without asking: a bypass mode,
|
||||
/// an allow-all permissions option, or a bypass approval policy.
|
||||
pub(crate) fn bypass_approvals(config: &[AgentConfigOption]) -> bool {
|
||||
let selected = |category: &str| {
|
||||
config.iter().find(|o| o.category.as_deref() == Some(category)).and_then(|o| o.current.as_deref())
|
||||
};
|
||||
selected("mode").is_some_and(is_bypass_mode)
|
||||
|| selected("permissions").is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true"))
|
||||
|| selected("approval").is_some_and(|v| v.eq_ignore_ascii_case("bypass"))
|
||||
}
|
||||
|
||||
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
|
||||
pub(crate) fn bypass_mode(options: &[AgentConfigOption]) -> Option<(&AgentConfigOption, &AgentConfigValue)> {
|
||||
let option = options.iter().find(|o| o.category.as_deref() == Some("mode"))?;
|
||||
let value = option.values.iter().find(|v| {
|
||||
let m: String = v.value.to_lowercase().chars().filter(char::is_ascii_lowercase).collect();
|
||||
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
|
||||
})?;
|
||||
let value = option.values.iter().find(|v| is_bypass_mode(&v.value))?;
|
||||
Some((option, value))
|
||||
}
|
||||
|
||||
fn is_bypass_mode(mode: &str) -> bool {
|
||||
// Mode ids can be URL-like (`…/session-modes#yolo`), as the composer's mode icon also assumes.
|
||||
let lower = mode.to_lowercase();
|
||||
let m: String =
|
||||
lower.rsplit(['#', '/']).next().unwrap_or_default().chars().filter(char::is_ascii_lowercase).collect();
|
||||
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
|
||||
}
|
||||
|
||||
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
|
||||
/// plus the older `modes` / `models` fields for what those don't cover.
|
||||
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
|
||||
@@ -133,15 +146,22 @@ pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static
|
||||
}
|
||||
|
||||
/// `available_commands_update`.
|
||||
pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
|
||||
pub(crate) fn commands_of(update: &Value, provider: &str) -> Vec<AgentCommand> {
|
||||
update
|
||||
.get("availableCommands")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|c| {
|
||||
let name = c.get("name")?.as_str()?.trim_start_matches('/');
|
||||
let source = if provider == "codex-acp" && name.starts_with('$') {
|
||||
AgentCommandSource::Skill
|
||||
} else {
|
||||
c.get("source").and_then(|s| serde_json::from_value(s.clone()).ok()).unwrap_or_default()
|
||||
};
|
||||
Some(AgentCommand {
|
||||
name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(),
|
||||
name: name.to_owned(),
|
||||
source,
|
||||
description: str_of(c, "description").unwrap_or_default(),
|
||||
hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
})
|
||||
@@ -267,12 +287,85 @@ mod tests {
|
||||
assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_bypass_settings_approve_without_asking() {
|
||||
let full_access = options_of(&json!({
|
||||
"modes": {"currentModeId": "full-access", "availableModes": [
|
||||
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
|
||||
]}
|
||||
}));
|
||||
assert!(bypass_approvals(&full_access));
|
||||
assert_eq!(bypass_mode(&full_access).map(|(_, v)| v.value.as_str()), Some("full-access"));
|
||||
|
||||
let asking = options_of(&json!({
|
||||
"modes": {"currentModeId": "ask", "availableModes": [
|
||||
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
|
||||
]}
|
||||
}));
|
||||
assert!(!bypass_approvals(&asking));
|
||||
assert_eq!(
|
||||
bypass_mode(&asking).map(|(_, v)| v.value.as_str()),
|
||||
Some("full-access"),
|
||||
"the offered bypass mode stays available"
|
||||
);
|
||||
|
||||
let permissions = |current: &str| {
|
||||
options_of(&json!({"configOptions": [{
|
||||
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select",
|
||||
"currentValue": current, "options": [{"value": "off"}, {"value": "on"}]
|
||||
}]}))
|
||||
};
|
||||
assert!(bypass_approvals(&permissions("on")));
|
||||
assert!(bypass_approvals(&permissions("TRUE")));
|
||||
assert!(!bypass_approvals(&permissions("off")));
|
||||
|
||||
let approval = options_of(&json!({"configOptions": [{
|
||||
"id": "approval", "name": "Approvals", "category": "approval", "type": "select",
|
||||
"currentValue": "bypass", "options": [{"value": "ask"}, {"value": "bypass"}]
|
||||
}]}));
|
||||
assert!(bypass_approvals(&approval));
|
||||
|
||||
// The composer's mode icon splits URL-like ids on `#` / `/`; the backend must agree.
|
||||
let url_mode = options_of(&json!({
|
||||
"modes": {"currentModeId": "https://agentclientprotocol.com/protocol/session-modes#yolo",
|
||||
"availableModes": [{"id": "https://agentclientprotocol.com/protocol/session-modes#yolo"}]}
|
||||
}));
|
||||
assert!(bypass_approvals(&url_mode));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_sources_in_updates_and_stored_tasks() {
|
||||
let update = json!({"availableCommands": [
|
||||
{"name": "review", "source": "skill"},
|
||||
{"name": "$lint"},
|
||||
{"name": "compact"}
|
||||
]});
|
||||
let sources = |provider| commands_of(&update, provider).into_iter().map(|c| c.source).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
sources("codex-acp"),
|
||||
[AgentCommandSource::Skill, AgentCommandSource::Skill, AgentCommandSource::Command]
|
||||
);
|
||||
assert_eq!(
|
||||
sources("custom"),
|
||||
[AgentCommandSource::Skill, AgentCommandSource::Command, AgentCommandSource::Command]
|
||||
);
|
||||
let old: AgentCommand =
|
||||
serde_json::from_value(json!({"name": "review", "description": "Review", "hint": null})).unwrap();
|
||||
assert_eq!(old.source, AgentCommandSource::Command);
|
||||
let skill = AgentCommand { source: AgentCommandSource::Skill, ..old };
|
||||
assert_eq!(serde_json::from_value::<AgentCommand>(serde_json::to_value(&skill).unwrap()).unwrap(), skill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_and_usage() {
|
||||
let cmds = commands_of(&json!({"availableCommands": [
|
||||
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
|
||||
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
|
||||
]}));
|
||||
let cmds = commands_of(
|
||||
&json!({"availableCommands": [
|
||||
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
|
||||
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
|
||||
]}),
|
||||
"claude",
|
||||
);
|
||||
assert_eq!(cmds[0].source, AgentCommandSource::Command);
|
||||
assert_eq!(cmds[0].hint.as_deref(), Some("what to review"));
|
||||
assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None));
|
||||
let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap();
|
||||
|
||||
+102
-20
@@ -100,6 +100,8 @@ struct Shared {
|
||||
providers: RwLock<Vec<provider::ProviderSpec>>,
|
||||
/// Last probe of each provider.
|
||||
probes: Mutex<HashMap<String, AgentProbe>>,
|
||||
/// One pre-session command lookup per provider, including failed attempts.
|
||||
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
|
||||
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
|
||||
idle_release_ms: AtomicU64,
|
||||
}
|
||||
@@ -151,6 +153,40 @@ impl Shared {
|
||||
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn fetch_commands(self: &Arc<Self>, spec: provider::ProviderSpec) {
|
||||
let mut ready = {
|
||||
let mut fetches = self.command_fetches.lock();
|
||||
fetches
|
||||
.entry(spec.id.clone())
|
||||
.or_insert_with(|| {
|
||||
let (done, ready) = tokio::sync::watch::channel(false);
|
||||
let shared = self.clone();
|
||||
// Keep the lookup alive if a composer unmounts while it is waiting.
|
||||
self.runtime.spawn(async move {
|
||||
if let Ok(commands) =
|
||||
native::claude::commands(spec.clone(), shared.paths.project_root.clone()).await
|
||||
{
|
||||
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
if shared.known_commands(&spec.id).is_empty() {
|
||||
shared.remember(&spec.id, None, Some(&commands), None);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let _ = done.send(true);
|
||||
});
|
||||
ready
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
while !*ready.borrow_and_update() {
|
||||
if ready.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn root_key(&self) -> String {
|
||||
self.paths.project_root.to_string_lossy().into_owned()
|
||||
}
|
||||
@@ -251,6 +287,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 {
|
||||
@@ -316,6 +353,7 @@ impl AgentManager {
|
||||
known: Mutex::new(known),
|
||||
providers,
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
command_fetches: Mutex::new(HashMap::new()),
|
||||
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
|
||||
});
|
||||
let mut tasks = HashMap::new();
|
||||
@@ -419,9 +457,21 @@ impl AgentManager {
|
||||
// Commands the agent implements itself and never lists (its CLI builtins):
|
||||
// merged without duplicating what `availableCommands` or the disk offer.
|
||||
let mut offered = offered;
|
||||
if task.is_none()
|
||||
&& offered.is_empty()
|
||||
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
|
||||
{
|
||||
self.shared.fetch_commands(spec).await;
|
||||
offered = self.shared.known_commands(&provider);
|
||||
}
|
||||
for &(name, description) in builtin_commands(&provider) {
|
||||
if !offered.iter().any(|c| c.name == name) {
|
||||
offered.push(AgentCommand { name: name.to_owned(), description: description.to_owned(), hint: None });
|
||||
offered.push(AgentCommand {
|
||||
name: name.to_owned(),
|
||||
description: description.to_owned(),
|
||||
hint: None,
|
||||
source: AgentCommandSource::Command,
|
||||
});
|
||||
}
|
||||
}
|
||||
if provider != "claude" && provider != "claude-acp" {
|
||||
@@ -696,7 +746,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
|
||||
@@ -1394,13 +1444,22 @@ impl Task {
|
||||
};
|
||||
return Failure { message, limit: Some(resets) };
|
||||
}
|
||||
match e {
|
||||
// Signed out, or the account was refused (Claude Code: "API Error: 403").
|
||||
RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => {
|
||||
Failure::from(self.live.lock().as_ref().map(|live| live.sign_in_message.clone()).unwrap_or(message))
|
||||
}
|
||||
e => Failure::from(self.describe(e)),
|
||||
let guidance = self.live.lock().as_ref().map(|live| live.sign_in_message.clone());
|
||||
Failure::from(self.sign_in_error(&e, guidance.as_deref()).unwrap_or_else(|| self.describe(e)))
|
||||
}
|
||||
|
||||
fn sign_in_error(&self, e: &RpcError, guidance: Option<&str>) -> Option<String> {
|
||||
let RpcError::Server { code, message, .. } = e else { return None };
|
||||
if !acp::auth_required(*code, message) {
|
||||
return None;
|
||||
}
|
||||
tracing::warn!(task = %self.id, code, %message, "agent sign-in failed");
|
||||
let Some(guidance) = guidance else { return Some(message.clone()) };
|
||||
Some(if message.trim().is_empty() || guidance.contains(message) {
|
||||
guidance.to_owned()
|
||||
} else {
|
||||
format!("{guidance}\n\n{message}")
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_session(self: &Arc<Self>) -> Result<Live, String> {
|
||||
@@ -1419,7 +1478,6 @@ impl Task {
|
||||
let spec = tokio::task::spawn_blocking(move || spec.resolve_for_launch(&launch_cwd))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let sign_in_message = spec.sign_in_message();
|
||||
self.stderr.lock().clear();
|
||||
self.config_sent.lock().clear();
|
||||
let weak = Arc::downgrade(self);
|
||||
@@ -1439,7 +1497,9 @@ impl Task {
|
||||
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", spec.name, spec.command))?;
|
||||
|
||||
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();
|
||||
@@ -1448,7 +1508,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 {
|
||||
@@ -1461,14 +1521,13 @@ 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| match e {
|
||||
RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => {
|
||||
sign_in_message.clone()
|
||||
}
|
||||
e => self.describe(e),
|
||||
})?;
|
||||
.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();
|
||||
(id, v)
|
||||
}
|
||||
@@ -1557,7 +1616,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)
|
||||
}
|
||||
@@ -1581,6 +1641,7 @@ impl Task {
|
||||
.collect()
|
||||
};
|
||||
if due.is_empty() {
|
||||
self.approve_pending();
|
||||
return;
|
||||
}
|
||||
let task = self.clone();
|
||||
@@ -1610,6 +1671,7 @@ impl Task {
|
||||
Err(e) => tracing::warn!("agent task {}: setting {} to {value} failed: {e}", task.id, option.id),
|
||||
}
|
||||
}
|
||||
task.approve_pending();
|
||||
task.persist();
|
||||
task.emit_task();
|
||||
});
|
||||
@@ -1699,7 +1761,7 @@ impl Task {
|
||||
true
|
||||
}
|
||||
"available_commands_update" => {
|
||||
s.task.commands = config::commands_of(update);
|
||||
s.task.commands = config::commands_of(update, &provider);
|
||||
true
|
||||
}
|
||||
"usage_update" => {
|
||||
@@ -1726,6 +1788,9 @@ impl Task {
|
||||
if kind == "config_option_update" && !self.replaying.load(Ordering::Acquire) {
|
||||
self.apply_late_choices();
|
||||
}
|
||||
if !self.replaying.load(Ordering::Acquire) && kind == "current_mode_update" {
|
||||
self.approve_pending();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.replaying.load(Ordering::Acquire) {
|
||||
@@ -1857,7 +1922,7 @@ impl Task {
|
||||
|
||||
fn approve_pending(&self) {
|
||||
let s = self.state.lock();
|
||||
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) {
|
||||
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.config) {
|
||||
return;
|
||||
}
|
||||
let mut pending = self.permissions.lock();
|
||||
@@ -2206,3 +2271,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": [] })]
|
||||
}
|
||||
@@ -156,6 +156,14 @@ pub struct AgentConfigOption {
|
||||
pub via: AgentConfigVia,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentCommandSource {
|
||||
#[default]
|
||||
Command,
|
||||
Skill,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCommand {
|
||||
@@ -164,6 +172,8 @@ pub struct AgentCommand {
|
||||
pub description: String,
|
||||
/// What to type after the command, when it takes input.
|
||||
pub hint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source: AgentCommandSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -69,6 +69,38 @@ pub(crate) fn connect(
|
||||
Ok(super::serve(shim, client))
|
||||
}
|
||||
|
||||
/// Discover commands without a prompt, ACP session, or pending session file.
|
||||
pub(crate) async fn commands(spec: ProviderSpec, cwd: PathBuf) -> Result<Vec<Value>, String> {
|
||||
tokio::time::timeout(Duration::from_secs(5), async move {
|
||||
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
|
||||
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
|
||||
let mut args = spec.args;
|
||||
args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args());
|
||||
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
|
||||
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
|
||||
let _stop = io.kill.clone().drop_guard();
|
||||
let request = json!({ "type": "control_request", "request_id": "commands",
|
||||
"request": { "subtype": "initialize", "perTaskStopAffordance": true } });
|
||||
io.stdin.write_all(format!("{request}\n").as_bytes()).await.map_err(|e| e.to_string())?;
|
||||
io.stdin.flush().await.map_err(|e| e.to_string())?;
|
||||
let mut lines = BufReader::new(io.stdout).lines();
|
||||
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
if msg["type"] == "control_response" && msg["response"]["request_id"] == "commands" {
|
||||
let response = &msg["response"];
|
||||
return if response["subtype"] == "success" {
|
||||
Ok(Settings::from_cli(&response["response"], &Value::Null).commands)
|
||||
} else {
|
||||
Err("Claude Code did not initialize".into())
|
||||
};
|
||||
}
|
||||
}
|
||||
Err("Claude Code exited".into())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
|
||||
}
|
||||
|
||||
struct Config {
|
||||
pub program: String,
|
||||
/// Extra arguments (a custom agent's), before the session's own.
|
||||
@@ -98,6 +130,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 +297,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 +360,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 +369,13 @@ impl Shim {
|
||||
}
|
||||
"session/new" => {
|
||||
let id = new_uuid(false);
|
||||
let session = self.add_session(&id, self.cwd_of(¶ms), Start::New { id: id.clone() }, false);
|
||||
let session = self.add_session(
|
||||
&id,
|
||||
self.cwd_of(¶ms),
|
||||
Start::New { id: id.clone() },
|
||||
false,
|
||||
mcp_config(¶ms),
|
||||
);
|
||||
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 +391,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(¶ms));
|
||||
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
|
||||
if method == "session/load" {
|
||||
session.replay(&chain);
|
||||
@@ -362,7 +417,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(¶ms));
|
||||
session.st.lock().settings = settings;
|
||||
let options = session.st.lock().settings.options();
|
||||
Ok(json!({ "sessionId": id, "configOptions": options }))
|
||||
@@ -556,6 +611,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 +1403,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());
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod translate;
|
||||
mod tests;
|
||||
|
||||
use super::Shim;
|
||||
use crate::acp::AUTH_REQUIRED;
|
||||
use crate::provider::ProviderSpec;
|
||||
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
|
||||
use parking_lot::Mutex;
|
||||
@@ -27,8 +28,6 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
|
||||
const EARLY_CANCEL: Duration = Duration::from_secs(1);
|
||||
/// How long the client's connection outlives codex.
|
||||
const EXIT_GRACE: Duration = Duration::from_millis(100);
|
||||
/// ACP's error code for a request that needs the user to sign in.
|
||||
const AUTH_REQUIRED: i64 = -32000;
|
||||
|
||||
pub(crate) fn connect(
|
||||
spec: &ProviderSpec,
|
||||
@@ -204,7 +203,7 @@ fn from_codex(e: RpcError) -> ResponseError {
|
||||
code,
|
||||
codes::PARSE_ERROR | codes::INVALID_REQUEST | codes::METHOD_NOT_FOUND | codes::INVALID_PARAMS
|
||||
);
|
||||
let code = if !protocol && message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
|
||||
let code = if !protocol && crate::acp::auth_required(code, &message) { AUTH_REQUIRED } else { code };
|
||||
ResponseError { code, message, data }
|
||||
}
|
||||
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
|
||||
@@ -264,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 } },
|
||||
},
|
||||
@@ -318,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"));
|
||||
@@ -330,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);
|
||||
@@ -345,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());
|
||||
|
||||
@@ -623,6 +623,23 @@ async fn auth_failure_names_auth() {
|
||||
assert!(start.get("jsonrpc").is_some(), "codex accepts the jsonrpc field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_errors_classify_authentication_without_matching_author_or_protocol_errors() {
|
||||
for (code, message, expected) in [
|
||||
(codes::INTERNAL_ERROR, "Missing author field", codes::INTERNAL_ERROR),
|
||||
(codes::INTERNAL_ERROR, "Unknown authority", codes::INTERNAL_ERROR),
|
||||
(codes::INTERNAL_ERROR, "Authentication failed: expired token", AUTH_REQUIRED),
|
||||
(codes::INTERNAL_ERROR, "Unauthorized", AUTH_REQUIRED),
|
||||
(codes::METHOD_NOT_FOUND, "authentication method not found", codes::METHOD_NOT_FOUND),
|
||||
(AUTH_REQUIRED, "Account is not eligible", AUTH_REQUIRED),
|
||||
] {
|
||||
let response = from_codex(RpcError::Server { code, message: message.into(), data: Some(json!({"detail": 1})) });
|
||||
assert_eq!(response.code, expected, "{message}");
|
||||
assert_eq!(response.message, message);
|
||||
assert_eq!(response.data, Some(json!({"detail": 1})));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_stop_sent_right_after_the_prompt_interrupts_the_turn() {
|
||||
let (results, _client, frames, _) = replay(Scenario::Interrupt).await;
|
||||
@@ -1027,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}))
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -92,9 +93,26 @@ impl ProviderSpec {
|
||||
render_login_hint(self, program, on_path.as_deref(), cfg!(windows))
|
||||
}
|
||||
|
||||
pub(crate) fn sign_in_message(&self) -> String {
|
||||
pub(crate) fn sign_in_message(&self, auth_methods: &[Value]) -> String {
|
||||
let name = &self.name;
|
||||
let hint = self.login_hint(Path::new(&self.command));
|
||||
let program = Path::new(&self.command);
|
||||
let terminal_args = matches!(self.source, AgentSource::Registry { .. })
|
||||
.then(|| {
|
||||
auth_methods.iter().find_map(|method| {
|
||||
if method.get("type").and_then(Value::as_str) != Some("terminal") {
|
||||
return None;
|
||||
}
|
||||
method.get("args")?.as_array()?.iter().map(Value::as_str).collect::<Option<Vec<_>>>()
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let hint = match terminal_args {
|
||||
Some(args) => {
|
||||
let command = render_login_command(program, args, cfg!(windows));
|
||||
format!("Run `{command}` in a terminal.")
|
||||
}
|
||||
None => self.login_hint(program),
|
||||
};
|
||||
format!("{name} needs you to sign in. {hint}")
|
||||
}
|
||||
|
||||
@@ -124,14 +142,8 @@ fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>
|
||||
if on_path.is_some_and(|path| same_path(path, program)) {
|
||||
return spec.login_hint.clone();
|
||||
}
|
||||
let path = program.to_string_lossy();
|
||||
let quoted = if windows {
|
||||
format!("& '{}'", path.replace('\'', "''"))
|
||||
} else {
|
||||
format!("'{}'", path.replace('\'', "'\\''"))
|
||||
};
|
||||
let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command");
|
||||
let command = format!("{quoted}{args}");
|
||||
let command = render_login_command(program, args.split_whitespace(), windows);
|
||||
match suffix {
|
||||
" in a terminal." => format!("Run `{command}` in a terminal."),
|
||||
" in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."),
|
||||
@@ -139,6 +151,22 @@ fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_login_command<'a>(program: &Path, args: impl IntoIterator<Item = &'a str>, windows: bool) -> String {
|
||||
let quote = |word: &str| {
|
||||
if windows { format!("'{}'", word.replace('\'', "''")) } else { format!("'{}'", word.replace('\'', "'\\''")) }
|
||||
};
|
||||
let mut command = format!("{}{}", if windows { "& " } else { "" }, quote(&program.to_string_lossy()));
|
||||
for arg in args {
|
||||
command.push(' ');
|
||||
if !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "-_=./".contains(c)) {
|
||||
command.push_str(arg);
|
||||
} else {
|
||||
command.push_str("e(arg));
|
||||
}
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
/// Whether `id` names a built-in agent that needs no registry install (native drivers
|
||||
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
|
||||
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
|
||||
@@ -370,6 +398,7 @@ fn is_executable(p: &Path) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
|
||||
@@ -429,12 +458,43 @@ mod tests {
|
||||
cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned()));
|
||||
let resolved = cursor.clone().resolve_for_launch(dir.path());
|
||||
assert_eq!(Path::new(&resolved.command), program);
|
||||
assert!(resolved.sign_in_message().contains(&*program.to_string_lossy()));
|
||||
assert!(resolved.sign_in_message(&[]).contains(&*program.to_string_lossy()));
|
||||
cursor.command = format!("tools/{command}");
|
||||
let resolved = cursor.resolve_for_launch(dir.path());
|
||||
assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_login_commands_quote_the_binary_and_each_argument() {
|
||||
for (windows, expected) in [
|
||||
(false, "'/agent'\\''s tools/$cli' auth login --account 'user name' 'a'\\''b' '$token' ''"),
|
||||
(true, "& '/agent''s tools/$cli' auth login --account 'user name' 'a''b' '$token' ''"),
|
||||
] {
|
||||
assert_eq!(
|
||||
render_login_command(
|
||||
Path::new("/agent's tools/$cli"),
|
||||
["auth", "login", "--account", "user name", "a'b", "$token", ""],
|
||||
windows
|
||||
),
|
||||
expected
|
||||
);
|
||||
}
|
||||
let mut spec = builtin_spec("cursor").unwrap();
|
||||
spec.id = "custom".into();
|
||||
spec.source = AgentSource::Registry { version: "1.4.5".into(), update: None };
|
||||
spec.login_hint = "Sign in with the agent's own command line first.".into();
|
||||
for method in [
|
||||
json!({"type": "terminal"}),
|
||||
json!({"type": "terminal", "args": ["login", null]}),
|
||||
json!({"type": "agent", "args": ["login"]}),
|
||||
] {
|
||||
assert_eq!(
|
||||
spec.sign_in_message(&[method]),
|
||||
"Cursor needs you to sign in. Sign in with the agent's own command line first."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn registry_login_hint_names_the_installed_executable() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! skills (`.claude/skills/<dir>/SKILL.md`) and custom commands (`.claude/commands/**/*.md`)
|
||||
//! of the working directory and every directory above it, then the user's `~/.claude`.
|
||||
|
||||
use crate::AgentCommand;
|
||||
use crate::{AgentCommand, AgentCommandSource};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -36,9 +36,14 @@ pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec<AgentCommand> {
|
||||
|
||||
/// `first` (the agent's own list) plus the commands of `more` it lacks.
|
||||
pub(crate) fn merge(first: Vec<AgentCommand>, more: Vec<AgentCommand>) -> Vec<AgentCommand> {
|
||||
let names: HashSet<String> = first.iter().map(|c| c.name.clone()).collect();
|
||||
let mut out = first;
|
||||
out.extend(more.into_iter().filter(|c| !names.contains(&c.name)));
|
||||
for c in more {
|
||||
if let Some(offered) = out.iter_mut().find(|offered| offered.name == c.name) {
|
||||
offered.source = c.source;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -61,7 +66,7 @@ fn skills(dir: &Path) -> Vec<AgentCommand> {
|
||||
return None;
|
||||
}
|
||||
let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?;
|
||||
Some(meta.command(name, &text))
|
||||
Some(meta.command(name, &text, AgentCommandSource::Skill))
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
@@ -79,7 +84,7 @@ fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<AgentCommand>)
|
||||
} else if let Some(stem) = name.strip_suffix(".md")
|
||||
&& let Ok(text) = std::fs::read_to_string(&path)
|
||||
{
|
||||
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text));
|
||||
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text, AgentCommandSource::Command));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,7 +152,7 @@ impl Frontmatter {
|
||||
}
|
||||
|
||||
/// `description`, else the body's first line.
|
||||
fn command(&self, name: String, text: &str) -> AgentCommand {
|
||||
fn command(&self, name: String, text: &str, source: AgentCommandSource) -> AgentCommand {
|
||||
let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| {
|
||||
let body = text.get(self.body..).unwrap_or_default();
|
||||
body.lines()
|
||||
@@ -158,6 +163,7 @@ impl Frontmatter {
|
||||
});
|
||||
AgentCommand {
|
||||
name,
|
||||
source,
|
||||
description: truncate(&description),
|
||||
hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
}
|
||||
@@ -194,13 +200,20 @@ mod tests {
|
||||
}
|
||||
|
||||
fn cmd(name: &str, description: &str) -> AgentCommand {
|
||||
AgentCommand { name: name.into(), description: description.into(), hint: None }
|
||||
AgentCommand {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
hint: None,
|
||||
source: AgentCommandSource::Command,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_and_user_skills_and_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("home");
|
||||
// Home is an ancestor of the project, so the walk stops there instead of climbing
|
||||
// past it into the real profile (a temp dir under the user's home holds its skills).
|
||||
let home = tmp.path().to_path_buf();
|
||||
let repo = tmp.path().join("work").join("idea");
|
||||
let skills = repo.join(".claude/skills");
|
||||
write(
|
||||
@@ -227,6 +240,12 @@ mod tests {
|
||||
["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"]
|
||||
);
|
||||
let by = |n: &str| found.iter().find(|c| c.name == n).unwrap();
|
||||
for name in ["issue-fix", "triage", "verify", "lint"] {
|
||||
assert_eq!(by(name).source, AgentCommandSource::Skill);
|
||||
}
|
||||
for name in ["git:commit", "review", "parent", "brooks-review"] {
|
||||
assert_eq!(by(name).source, AgentCommandSource::Command);
|
||||
}
|
||||
assert_eq!(by("triage").description, "One pass over open issues and PRs");
|
||||
assert_eq!(by("issue-fix").description, "Fix an issue");
|
||||
assert_eq!(by("verify").description, "Build and drive the app");
|
||||
@@ -262,10 +281,14 @@ mod tests {
|
||||
fn the_agents_entries_win() {
|
||||
let merged = merge(
|
||||
vec![cmd("triage", "from the agent"), cmd("compact", "Compact")],
|
||||
vec![cmd("triage", "from disk"), cmd("verify", "Verify")],
|
||||
vec![
|
||||
AgentCommand { source: AgentCommandSource::Skill, ..cmd("triage", "from disk") },
|
||||
cmd("verify", "Verify"),
|
||||
],
|
||||
);
|
||||
assert_eq!(names(&merged), ["triage", "compact", "verify"]);
|
||||
assert_eq!(merged[0].description, "from the agent");
|
||||
assert_eq!(merged[0].source, AgentCommandSource::Skill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -35,6 +35,10 @@ struct FakeAgent {
|
||||
/// Without `loadSession` (a session lives only as long as its process).
|
||||
no_load: bool,
|
||||
auth_on_new: bool,
|
||||
request_error: Option<(i64, String)>,
|
||||
auth_methods: Vec<Value>,
|
||||
/// Hold the late mode reply until the prompt's permission request is already waiting.
|
||||
hold_late_mode: bool,
|
||||
empty_config: AtomicBool,
|
||||
/// Config options `session/new` reports after the model one (per-model efforts, say).
|
||||
extra_options: Mutex<Vec<Value>>,
|
||||
@@ -126,7 +130,17 @@ impl Handler for FakeAgent {
|
||||
if (method == "session/new" && self.auth_on_new)
|
||||
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
|
||||
{
|
||||
return Err((codes::INTERNAL_ERROR, "Authentication required".into()));
|
||||
return Err(self
|
||||
.request_error
|
||||
.clone()
|
||||
.unwrap_or((codes::INTERNAL_ERROR, "Authentication required".into())));
|
||||
}
|
||||
if self.hold_late_mode
|
||||
&& method == "session/set_config_option"
|
||||
&& params.get("value").and_then(Value::as_str) == Some("full-access")
|
||||
{
|
||||
// Let the prompt's permission request reach the task before the mode changes.
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
|
||||
match method {
|
||||
@@ -134,8 +148,10 @@ impl Handler for FakeAgent {
|
||||
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
|
||||
self.calls.lock().push("subagents".into());
|
||||
}
|
||||
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": !self.no_load,
|
||||
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}))
|
||||
Ok(
|
||||
json!({"protocolVersion": 1, "authMethods": self.auth_methods, "agentCapabilities": {"loadSession": !self.no_load,
|
||||
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}),
|
||||
)
|
||||
}
|
||||
"session/new" => {
|
||||
if self.empty_config.load(Ordering::Acquire) {
|
||||
@@ -200,6 +216,29 @@ impl Handler for FakeAgent {
|
||||
self.say(&session, "ok");
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "late full access" {
|
||||
// The mode option appears only now; the user's choice is applied late.
|
||||
self.update(
|
||||
&session,
|
||||
json!({"sessionUpdate": "config_option_update", "configOptions": [
|
||||
{"id": "mode", "category": "mode", "type": "select", "currentValue": "ask",
|
||||
"options": [{"value": "ask", "name": "Ask"}, {"value": "full-access", "name": "Full Access"}]}
|
||||
]}),
|
||||
);
|
||||
let peer = self.latest();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
.block_on(peer.request(
|
||||
"session/request_permission",
|
||||
json!({"sessionId": session, "toolCall": {"toolCallId": "late", "title": "Run cargo test"},
|
||||
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
|
||||
{"optionId": "no", "name": "Reject", "kind": "reject_once"}]}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
|
||||
self.say(&session, &format!("late chose {chosen}"));
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "ask" {
|
||||
let peer = self.latest();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
@@ -300,6 +339,9 @@ fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
|
||||
}
|
||||
|
||||
fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentManager {
|
||||
// These tests speak to FakeAgent, never an installed CLI, including after a provider reload.
|
||||
let agents = dir.join("agents");
|
||||
provider::set_builtin_command(&agents, "claude", Some(&dir.join("missing-claude").to_string_lossy())).unwrap();
|
||||
let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| {
|
||||
let (a, b) = duplex(1 << 20);
|
||||
let (ar, aw) = tokio::io::split(a);
|
||||
@@ -314,14 +356,97 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
|
||||
store_dir: dir.join("store"),
|
||||
worktrees_dir: dir.join("worktrees"),
|
||||
models_file: models_file.to_path_buf(),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
custom_agents_dir: Some(agents),
|
||||
// The project is the boundary of the skill walk: nothing above it (such as the
|
||||
// user's profile, when temp lives under it) leaks into the test.
|
||||
home_dir: Some(dir.to_path_buf()),
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn claude_commands_before_session_are_fetched_once_and_saved() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
for outcome in ["success", "error", "exit", "timeout", "missing"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let script = root.join("fake-claude");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"#!/usr/bin/env python3
|
||||
import json, os, pathlib, sys, time
|
||||
root = pathlib.Path.cwd()
|
||||
with (root / 'launches').open('a') as f:
|
||||
f.write(json.dumps(sys.argv[1:]) + '\n')
|
||||
request = json.loads(sys.stdin.readline())
|
||||
with (root / 'requests').open('a') as f:
|
||||
f.write(json.dumps(request) + '\n')
|
||||
outcome = os.environ['OUTCOME']
|
||||
if outcome == 'exit':
|
||||
sys.exit(1)
|
||||
if outcome == 'timeout':
|
||||
time.sleep(60)
|
||||
response = {'subtype': outcome, 'request_id': request['request_id']}
|
||||
if outcome == 'success':
|
||||
response['response'] = {'commands': [
|
||||
{'name': 'compact', 'description': 'Free up context', 'argumentHint': '[instructions]'},
|
||||
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}]}
|
||||
else:
|
||||
response['error'] = 'Authentication required'
|
||||
print(json.dumps({'type': 'control_response', 'response': response}), flush=True)
|
||||
for line in sys.stdin:
|
||||
with (root / 'requests').open('a') as f:
|
||||
f.write(line)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::create_dir_all(root.join(".claude/skills/triage")).unwrap();
|
||||
std::fs::write(root.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n")
|
||||
.unwrap();
|
||||
let mgr = manager(root, Arc::new(FakeAgent::default()));
|
||||
{
|
||||
let mut providers = mgr.shared.providers.write();
|
||||
let spec = providers.iter_mut().find(|p| p.id == "claude").unwrap();
|
||||
if outcome != "missing" {
|
||||
spec.command = script.to_string_lossy().into_owned();
|
||||
}
|
||||
spec.env = vec![("OUTCOME".into(), outcome.into())];
|
||||
}
|
||||
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), mgr.commands("claude", None));
|
||||
let first = first.unwrap();
|
||||
assert_eq!(first, concurrent.unwrap());
|
||||
assert_eq!(first, mgr.commands("claude", None).await.unwrap());
|
||||
let names: Vec<_> = first.iter().map(|c| c.name.as_str()).collect();
|
||||
if outcome == "success" {
|
||||
assert_eq!(names, ["compact", "clear", "model", "context", "triage"]);
|
||||
assert_eq!(first[0].hint.as_deref(), Some("[instructions]"));
|
||||
let restored = manager(root, Arc::new(FakeAgent::default()));
|
||||
assert_eq!(first, restored.commands("claude", None).await.unwrap());
|
||||
} else {
|
||||
assert_eq!(names, ["triage"], "{outcome}");
|
||||
assert!(!root.join("models.json").exists());
|
||||
}
|
||||
assert!(mgr.tasks.read().is_empty());
|
||||
assert!(!root.join("store").exists());
|
||||
if outcome != "missing" {
|
||||
let launches = std::fs::read_to_string(root.join("launches")).unwrap();
|
||||
assert_eq!(launches.lines().count(), 1, "{outcome}");
|
||||
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
|
||||
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
|
||||
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
|
||||
assert_eq!(requests.lines().count(), 1, "only initialize, no prompt or other control request");
|
||||
let request: Value = serde_json::from_str(requests.trim()).unwrap();
|
||||
assert_eq!(request["type"], "control_request");
|
||||
assert_eq!(request["request"]["subtype"], "initialize");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn authentication_failures_name_the_session_executable_at_start_and_during_prompts() {
|
||||
for auth_on_new in [true, false] {
|
||||
@@ -360,7 +485,110 @@ async fn authentication_failures_name_the_session_executable_at_start_and_during
|
||||
.expect("authentication error");
|
||||
assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}");
|
||||
assert!(error.contains(&*program.to_string_lossy()), "{error}");
|
||||
assert!(error.ends_with(" login` in a terminal."), "{error}");
|
||||
assert!(error.ends_with(" login` in a terminal.\n\nAuthentication required"), "{error}");
|
||||
mgr.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn sign_in_errors_preserve_details_and_leave_unrelated_errors_unchanged() {
|
||||
for auth_on_new in [true, false] {
|
||||
for (code, message, sign_in) in [
|
||||
(-32000, "This Google account is not eligible for this agent.", true),
|
||||
(codes::INTERNAL_ERROR, "Authentication failed: token expired for account alice.", true),
|
||||
(codes::INTERNAL_ERROR, "UNAUTHORIZED: account refused", true),
|
||||
(codes::INTERNAL_ERROR, "You are not logged in to this installation.", true),
|
||||
(codes::INTERNAL_ERROR, "Please sign in with your work account.", true),
|
||||
(codes::INTERNAL_ERROR, "Missing author field", false),
|
||||
(codes::INTERNAL_ERROR, "Unknown authority in configuration", false),
|
||||
] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent =
|
||||
Arc::new(FakeAgent { auth_on_new, request_error: Some((code, message.into())), ..Default::default() });
|
||||
let mgr = manager(tmp.path(), agent);
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("again"),
|
||||
provider: "cursor".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
if !auth_on_new {
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
mgr.prompt(&task.id, text("auth"), false).await.unwrap();
|
||||
}
|
||||
wait_for(&mgr, &task.id, AgentStatus::Failed).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
let error = entries
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Error { message, .. } => Some(message),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
if sign_in {
|
||||
assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}");
|
||||
assert!(error.ends_with(&format!("\n\n{message}")), "{error}");
|
||||
} else {
|
||||
assert_eq!(error, message);
|
||||
}
|
||||
mgr.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn registry_sign_in_errors_use_the_advertised_terminal_login() {
|
||||
for terminal in [true, false] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent {
|
||||
auth_on_new: true,
|
||||
request_error: Some((-32000, "This account is not eligible.".into())),
|
||||
auth_methods: vec![json!({"type": if terminal { "terminal" } else { "agent" },
|
||||
"id": "cli", "name": "CLI", "args": ["auth", "login"]})],
|
||||
..Default::default()
|
||||
});
|
||||
let mgr = manager(tmp.path(), agent);
|
||||
let program = tmp.path().join("Registry Tools").join("antigravity.cmd");
|
||||
let mut spec = provider::builtin_spec("cursor").unwrap();
|
||||
spec.id = "antigravity".into();
|
||||
spec.name = "Google Antigravity".into();
|
||||
spec.command = program.to_string_lossy().into_owned();
|
||||
spec.source = AgentSource::Registry { version: "1.4.5".into(), update: None };
|
||||
spec.login_hint = "Sign in with the agent's own command line first.".into();
|
||||
mgr.shared.providers.write().push(spec);
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("again"),
|
||||
provider: "antigravity".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Failed).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
let error = entries
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Error { message, .. } => Some(message),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(error.starts_with("Google Antigravity needs you to sign in. "), "{error}");
|
||||
if terminal {
|
||||
assert!(error.contains(&*program.to_string_lossy()), "{error}");
|
||||
assert!(error.contains(" auth login` in a terminal."), "{error}");
|
||||
} else {
|
||||
assert!(error.contains("Sign in with the agent's own command line first."), "{error}");
|
||||
}
|
||||
assert!(error.ends_with("\n\nThis account is not eligible."), "{error}");
|
||||
mgr.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -433,6 +661,7 @@ async fn builtin_commands_only_fill_verified_gaps_and_preserve_advertised_metada
|
||||
name: "compact".into(),
|
||||
description: "Agent supplied description".into(),
|
||||
hint: Some("instructions".into()),
|
||||
source: AgentCommandSource::Command,
|
||||
};
|
||||
mgr.task(&task.id).unwrap().state.lock().task.commands = vec![advertised.clone()];
|
||||
// The task's provider wins over the caller's provider; metadata is not replaced.
|
||||
@@ -1438,12 +1667,12 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
|
||||
mgr.prompt(&task.id, text("late option"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let t = mgr.task(&task.id).unwrap();
|
||||
assert!(config::bypass_approvals("cursor", &t.state.lock().task.config));
|
||||
assert!(config::bypass_approvals(&t.state.lock().task.config));
|
||||
t.kill();
|
||||
mgr.prompt(&task.id, text("Fix again"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(!agent.calls.lock().iter().any(|c| c.contains("bypass")));
|
||||
assert!(config::bypass_approvals("cursor", &mgr.shared.known_config("cursor")));
|
||||
assert!(config::bypass_approvals(&mgr.shared.known_config("cursor")));
|
||||
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "ask".into()).await.unwrap();
|
||||
mgr.prompt(&task.id, text("Fix once more"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
@@ -1534,6 +1763,133 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_full_access_mode_approves_tool_requests() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
*agent.extra_options.lock() = vec![json!({
|
||||
"id": "mode", "category": "mode", "type": "select", "currentValue": "ask",
|
||||
"options": [{"value": "ask", "name": "Ask"}, {"value": "full-access", "name": "Full Access"}]
|
||||
})];
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Fix"),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "mode".into(), value: "full-access".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"full-access\"")));
|
||||
assert!(
|
||||
mgr.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
|
||||
// Choosing Full Access while the request is already waiting also approves it.
|
||||
let waiting = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Fix"),
|
||||
provider: "claude".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &waiting.id, AgentStatus::NeedsInput).await;
|
||||
mgr.set_config(&waiting.id, "mode", "full-access".into()).await.unwrap();
|
||||
wait_for(&mgr, &waiting.id, AgentStatus::Ready).await;
|
||||
assert!(
|
||||
mgr.entries(&waiting.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_late_full_access_option_approves_a_pending_request() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent { hold_late_mode: true, ..Default::default() });
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("late full access"),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "mode".into(), value: "full-access".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let task = mgr.task(&task.id).unwrap();
|
||||
let (status, busy) = {
|
||||
let s = task.state.lock();
|
||||
(s.task.status, s.busy)
|
||||
};
|
||||
if status == AgentStatus::Ready && !busy {
|
||||
break;
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
let _ = mgr.cancel(&task.id, None);
|
||||
mgr.shutdown();
|
||||
panic!("late Full Access did not approve the pending request");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"full-access\"")));
|
||||
assert!(
|
||||
mgr.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_allow_all_permissions_option_approves_tool_requests() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
*agent.extra_options.lock() = vec![json!({
|
||||
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select", "currentValue": "off",
|
||||
"options": [{"value": "off", "name": "Ask"}, {"value": "on", "name": "Full Access"}]
|
||||
})];
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Fix"),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "allow_all".into(), value: "on".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"on\"")));
|
||||
assert!(
|
||||
mgr.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn archived_tasks_can_be_restored_and_continue_their_session() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1585,6 +1941,7 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
||||
};
|
||||
git(&["init", "-q", "-b", "member-main"]);
|
||||
git(&["config", "core.autocrlf", "false"]);
|
||||
std::fs::write(member.join("file.txt"), "original\n").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["commit", "-qm", "initial"]);
|
||||
@@ -1898,6 +2255,7 @@ async fn delete_running_agent(no_load: bool) {
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
git(&["init", "-q", "-b", "main"]);
|
||||
git(&["config", "core.autocrlf", "false"]);
|
||||
std::fs::write(root.join("file.txt"), "original\n").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["commit", "-qm", "initial"]);
|
||||
@@ -1986,3 +2344,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":[]})]
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ pub fn load_linked(
|
||||
gradle_settings: &ide_core::settings::GradleSettings,
|
||||
) -> BuildModel {
|
||||
let maven = maven::has_projects(root, settings).then(|| {
|
||||
let profiles = sync::maven_profiles(cache_dir);
|
||||
let profiles = settings.profiles(&sync::maven_profiles(cache_dir));
|
||||
let repo = maven_repo.or_else(maven::default_local_repo);
|
||||
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
|
||||
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
|
||||
@@ -222,6 +222,30 @@ mod tests {
|
||||
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_profiles_and_checkbox_choices_control_modules_before_sync() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join("extra")).unwrap();
|
||||
std::fs::write(root.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
|
||||
std::fs::write(
|
||||
root.join("pom.xml"),
|
||||
r#"<project><artifactId>root</artifactId><packaging>pom</packaging>
|
||||
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cache = root.join("cache");
|
||||
let settings = ide_core::settings::MavenSettings { active_profiles: "extra".into(), ..Default::default() };
|
||||
let modules = |settings: &ide_core::settings::MavenSettings| {
|
||||
load_linked(root, &cache, None, settings, &Default::default()).maven.unwrap().projects[0].modules.clone()
|
||||
};
|
||||
assert!(modules(&Default::default()).is_empty());
|
||||
assert_eq!(modules(&settings), ["extra/pom.xml"]);
|
||||
assert_eq!(maven::load_linked(root, &settings).projects[0].modules, ["extra/pom.xml"]);
|
||||
save_maven_profiles(&cache, &["!extra".into()]).unwrap();
|
||||
assert!(modules(&settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_build_systems_and_build_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1063,7 +1063,7 @@ pub fn load(root: &Path) -> MavenModel {
|
||||
|
||||
/// [`load`] with the POMs `settings` link (Add Maven Projects).
|
||||
pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
|
||||
load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
|
||||
load_with_linked(root, default_local_repo(), &settings.profiles(&[]), &settings.linked_projects)
|
||||
}
|
||||
|
||||
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
|
||||
|
||||
@@ -304,7 +304,7 @@ pub fn sync_maven(
|
||||
let mut model = maven::load_with_linked(
|
||||
root,
|
||||
maven::default_local_repo(),
|
||||
&options.profiles,
|
||||
&options.maven_settings.profiles(&options.profiles),
|
||||
&options.maven_settings.linked_projects,
|
||||
);
|
||||
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "ide-cli"
|
||||
description = "The `openidea` command: arguments, the request it sends a running app over a local socket, and installing it on PATH"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
dirs.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
winreg = "0.56"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Command-line arguments of `openidea`.
|
||||
|
||||
use crate::Target;
|
||||
use std::path::Path;
|
||||
|
||||
pub const USAGE: &str = "\
|
||||
Usage: openidea [options] [[--line <n>] [--column <n>] path[:line[:column]] ...]
|
||||
openidea diff <left> <right>
|
||||
|
||||
Opens folders as projects and files in the editor of the running OpenIDEA,
|
||||
starting it if needed. Without paths, brings OpenIDEA to the front.
|
||||
Lines and columns start at 1; --line and --column apply to the next path.
|
||||
|
||||
Options:
|
||||
--line <n> Put the caret on line <n> of the next file
|
||||
--column <n> Put the caret in column <n> of that line
|
||||
-h, --help Show this help
|
||||
-v, --version Show the version
|
||||
";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Help,
|
||||
Version,
|
||||
Open(Vec<Target>),
|
||||
Diff(Vec<Target>),
|
||||
}
|
||||
|
||||
/// Parses `args` (without the program name); relative paths resolve against `cwd`.
|
||||
pub fn parse(args: &[String], cwd: &Path) -> Result<Command, String> {
|
||||
parse_with(args, cwd, &|p| p.exists())
|
||||
}
|
||||
|
||||
fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
|
||||
if args.first().is_some_and(|arg| arg == "diff") {
|
||||
return match parse_paths(&args[1..], cwd, exists)? {
|
||||
Command::Open(targets) if targets.len() == 2 => Ok(Command::Diff(targets)),
|
||||
Command::Help => Ok(Command::Help),
|
||||
_ => Err("diff needs two files".into()),
|
||||
};
|
||||
}
|
||||
parse_paths(args, cwd, exists)
|
||||
}
|
||||
|
||||
fn parse_paths(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
|
||||
let mut targets = Vec::new();
|
||||
let mut options = true;
|
||||
let (mut line, mut column) = (None, None);
|
||||
let mut args = args.iter();
|
||||
while let Some(arg) = args.next() {
|
||||
let (flag, inline) = match arg.split_once('=') {
|
||||
Some((flag, value)) if options && flag.starts_with("--") => (flag, Some(value.to_owned())),
|
||||
_ => (arg.as_str(), None),
|
||||
};
|
||||
match flag {
|
||||
"--" if options => options = false,
|
||||
"-h" | "--help" if options => return Ok(Command::Help),
|
||||
"-v" | "--version" if options => return Ok(Command::Version),
|
||||
"--line" | "--column" if options => {
|
||||
let value = inline.or_else(|| args.next().cloned()).ok_or(format!("{flag} needs a number"))?;
|
||||
let n = position(&value).ok_or(format!("{flag}: not a number from 1: {value}"))?;
|
||||
*if flag == "--line" { &mut line } else { &mut column } = Some(n);
|
||||
}
|
||||
a if options && a.starts_with('-') && a != "-" => return Err(format!("unknown option: {a}")),
|
||||
_ => {
|
||||
let mut target = target(arg, cwd, exists)?;
|
||||
if line.is_some() || column.is_some() {
|
||||
target.line = line.take().or(target.line).or(Some(1));
|
||||
target.column = column.take();
|
||||
}
|
||||
targets.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
if line.is_some() || column.is_some() {
|
||||
return Err("--line and --column need a file after them".into());
|
||||
}
|
||||
Ok(Command::Open(targets))
|
||||
}
|
||||
|
||||
/// `arg` as a path, else as `path:line[:column]` when `path` exists.
|
||||
fn target(arg: &str, cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Target, String> {
|
||||
let path = cwd.join(arg);
|
||||
if exists(&path) {
|
||||
return Ok(Target { path, line: None, column: None });
|
||||
}
|
||||
for (base, line, column) in split_position(arg) {
|
||||
let path = cwd.join(base);
|
||||
if exists(&path) {
|
||||
return Ok(Target { path, line: Some(line), column });
|
||||
}
|
||||
}
|
||||
Err(format!("{arg}: no such file or directory"))
|
||||
}
|
||||
|
||||
/// The readings of `arg` as `path:line:column`, then as `path:line`.
|
||||
fn split_position(arg: &str) -> Vec<(&str, u32, Option<u32>)> {
|
||||
let Some((rest, last)) = arg.rsplit_once(':') else { return Vec::new() };
|
||||
let Some(last) = position(last) else { return Vec::new() };
|
||||
let mut readings = Vec::new();
|
||||
if let Some((base, line)) = rest.rsplit_once(':')
|
||||
&& let Some(line) = position(line)
|
||||
&& !base.is_empty()
|
||||
{
|
||||
readings.push((base, line, Some(last)));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
readings.push((rest, last, None));
|
||||
}
|
||||
readings
|
||||
}
|
||||
|
||||
fn position(s: &str) -> Option<u32> {
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok().filter(|n| *n > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn args(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
fn parse_in(list: &[&str], existing: &[&str]) -> Result<Command, String> {
|
||||
let existing: Vec<PathBuf> = existing.iter().map(PathBuf::from).collect();
|
||||
parse_with(&args(list), Path::new("/work"), &|p| existing.iter().any(|e| e == p))
|
||||
}
|
||||
|
||||
fn at(path: &str, line: Option<u32>, column: Option<u32>) -> Target {
|
||||
Target { path: path.into(), line, column }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_resolve_against_cwd() {
|
||||
assert_eq!(
|
||||
parse_in(&["src", "/abs/a.rs"], &["/work/src", "/abs/a.rs"]),
|
||||
Ok(Command::Open(vec![at("/work/src", None, None), at("/abs/a.rs", None, None)]))
|
||||
);
|
||||
assert_eq!(parse_in(&[], &[]), Ok(Command::Open(vec![])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_split_only_when_the_whole_arg_is_missing() {
|
||||
let files = ["/work/a.rs"];
|
||||
assert_eq!(parse_in(&["a.rs:12"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), None)])));
|
||||
assert_eq!(parse_in(&["a.rs:12:5"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5))])));
|
||||
assert!(parse_in(&["a.rs:0"], &files).is_err());
|
||||
assert!(parse_in(&["a.rs:x"], &files).is_err());
|
||||
assert!(parse_in(&["missing.rs:3"], &files).is_err());
|
||||
}
|
||||
|
||||
// Windows file names cannot contain `:`, and `b:2` reads as a path on drive B.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_existing_name_with_a_colon_wins_over_a_position() {
|
||||
let files = ["/work/b:2"];
|
||||
assert_eq!(parse_in(&["b:2"], &files), Ok(Command::Open(vec![at("/work/b:2", None, None)])));
|
||||
assert_eq!(parse_in(&["b:2:7"], &files), Ok(Command::Open(vec![at("/work/b:2", Some(7), None)])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_drive_colon_is_not_a_position() {
|
||||
assert_eq!(split_position(r"C:\a.rs:10"), vec![(r"C:\a.rs", 10, None)]);
|
||||
assert_eq!(split_position(r"C:\a.rs:10:2"), vec![(r"C:\a.rs", 10, Some(2)), (r"C:\a.rs:10", 2, None)]);
|
||||
assert!(split_position(r"C:\a.rs").is_empty());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_absolute_and_unc_targets_keep_spaces_and_unicode() {
|
||||
let local = PathBuf::from(r"C:\项目\my file.rs");
|
||||
let network = PathBuf::from(r"\\server\share\folder name");
|
||||
let command = parse_with(
|
||||
&args(&[r"C:\项目\my file.rs:12:5", r"\\server\share\folder name"]),
|
||||
Path::new(r"D:\work"),
|
||||
&|path| path == local || path == network,
|
||||
);
|
||||
assert_eq!(
|
||||
command,
|
||||
Ok(Command::Open(vec![
|
||||
Target { path: local, line: Some(12), column: Some(5) },
|
||||
Target { path: network, line: None, column: None },
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_and_column_flags_apply_to_the_next_path() {
|
||||
let files = ["/work/a.rs", "/work/b.rs"];
|
||||
assert_eq!(
|
||||
parse_in(&["--line", "12", "--column=5", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5)), at("/work/b.rs", None, None)]))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_in(&["--line=3", "a.rs:9:9"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(3), None)]))
|
||||
);
|
||||
assert!(parse_in(&["--line", "0", "a.rs"], &files).is_err());
|
||||
assert!(parse_in(&["--line"], &files).is_err());
|
||||
assert!(parse_in(&["a.rs", "--line", "2"], &files).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options() {
|
||||
assert_eq!(parse_in(&["-h"], &[]), Ok(Command::Help));
|
||||
assert_eq!(parse_in(&["--version"], &[]), Ok(Command::Version));
|
||||
assert!(parse_in(&["--nope"], &[]).is_err());
|
||||
assert_eq!(parse_in(&["--", "-h"], &["/work/-h"]), Ok(Command::Open(vec![at("/work/-h", None, None)])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_diff_command_is_supported() {
|
||||
let files = ["/work/a.rs", "/work/b.rs"];
|
||||
assert_eq!(
|
||||
parse_in(&["diff", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Diff(vec![at("/work/a.rs", None, None), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
assert!(parse_in(&["diff", "a.rs"], &files).is_err());
|
||||
assert_eq!(
|
||||
parse_in(&["diff", "diff", "b.rs"], &["/work/diff", "/work/b.rs"]),
|
||||
Ok(Command::Diff(vec![at("/work/diff", None, None), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
assert_eq!(parse_in(&["--", "diff"], &["/work/diff"]), Ok(Command::Open(vec![at("/work/diff", None, None)])));
|
||||
assert_eq!(
|
||||
parse_in(&["--column=4", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(1), Some(4)), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Installing the `openidea` command: a symlink on `PATH` to the launcher
|
||||
//! script in a macOS bundle, or a wrapper in a user's bin directory.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const COMMAND: &str = "openidea";
|
||||
const LAUNCHER: &str = "Contents/Resources/bin/openidea";
|
||||
const SYSTEM_DIR: &str = "/usr/local/bin";
|
||||
|
||||
/// The launcher script of the bundle whose executable is `exe`; `None` outside a bundle.
|
||||
pub fn bundled_launcher(exe: &Path) -> Option<PathBuf> {
|
||||
let launcher = exe.parent()?.parent()?.parent()?.join(LAUNCHER);
|
||||
launcher.is_file().then_some(launcher)
|
||||
}
|
||||
|
||||
/// macOS runs a quarantined app from a random read-only copy until it is moved:
|
||||
/// a link there would break on the next launch.
|
||||
pub fn translocated(path: &Path) -> bool {
|
||||
path.to_string_lossy().contains("/AppTranslocation/")
|
||||
}
|
||||
|
||||
/// Where the link goes: `~/.local/bin` when it is on `path_var` (no administrator
|
||||
/// rights needed), else `/usr/local/bin`.
|
||||
pub fn link_dir(path_var: Option<&OsStr>, home: Option<&Path>) -> PathBuf {
|
||||
let local = home.map(|h| h.join(".local/bin"));
|
||||
match (path_var, local) {
|
||||
(Some(var), Some(local)) if std::env::split_paths(var).any(|p| p == local) => local,
|
||||
_ => PathBuf::from(SYSTEM_DIR),
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories an earlier install may have used.
|
||||
pub fn link_dirs(home: Option<&Path>) -> Vec<PathBuf> {
|
||||
home.map(|h| h.join(".local/bin")).into_iter().chain([PathBuf::from(SYSTEM_DIR)]).collect()
|
||||
}
|
||||
|
||||
/// The installed links among `dirs` (any OpenIDEA bundle's launcher).
|
||||
pub fn installed(dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
dirs.iter().map(|d| d.join(COMMAND)).filter(|l| is_launcher_link(l)).collect()
|
||||
}
|
||||
|
||||
fn is_launcher_link(link: &Path) -> bool {
|
||||
std::fs::read_link(link).is_ok_and(|target| target.ends_with(LAUNCHER))
|
||||
}
|
||||
|
||||
/// Links `dir/openidea` to `launcher`, asking for administrator rights when `dir`
|
||||
/// is not writable. Refuses to replace an `openidea` that is not ours.
|
||||
pub fn install(launcher: &Path, dir: &Path) -> io::Result<PathBuf> {
|
||||
let link = dir.join(COMMAND);
|
||||
if link.symlink_metadata().is_ok() && !is_launcher_link(&link) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} already exists and is not an OpenIDEA command", link.display()),
|
||||
));
|
||||
}
|
||||
let direct = std::fs::create_dir_all(dir).and_then(|()| {
|
||||
if link.symlink_metadata().is_ok() {
|
||||
std::fs::remove_file(&link)?;
|
||||
}
|
||||
symlink(launcher, &link)
|
||||
});
|
||||
match direct {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
|
||||
privileged(&format!("mkdir -p {} && ln -sfn {} {}", quote(dir), quote(launcher), quote(&link)))?
|
||||
}
|
||||
result => result?,
|
||||
}
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Removes `links`, asking for administrator rights where needed.
|
||||
pub fn uninstall(links: &[PathBuf]) -> io::Result<()> {
|
||||
for link in links {
|
||||
match std::fs::remove_file(link) {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => privileged(&format!("rm -f {}", quote(link)))?,
|
||||
result => result?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink(target: &Path, link: &Path) -> io::Result<()> {
|
||||
std::os::unix::fs::symlink(target, link)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn symlink(_target: &Path, _link: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on this platform"))
|
||||
}
|
||||
|
||||
/// Runs `command` as root after the system's password prompt. Cancelling it is
|
||||
/// [`io::ErrorKind::Interrupted`].
|
||||
fn privileged(command: &str) -> io::Result<()> {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "administrator rights required"));
|
||||
}
|
||||
let script = format!("do shell script \"{}\" with administrator privileges", applescript_escape(command));
|
||||
let output = std::process::Command::new("/usr/bin/osascript").arg("-e").arg(script).output()?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||
// "User canceled. (-128)"
|
||||
let kind = if message.contains("(-128)") { io::ErrorKind::Interrupted } else { io::ErrorKind::PermissionDenied };
|
||||
Err(io::Error::new(kind, message))
|
||||
}
|
||||
|
||||
fn quote(path: &Path) -> String {
|
||||
format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
fn applescript_escape(s: &str) -> String {
|
||||
s.replace('\\', r"\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
const WRAPPER_MARKER: &str = "OpenIDEA command launcher v1";
|
||||
|
||||
/// AppImages remount their executable on every launch. Keep the original image path.
|
||||
pub fn stable_executable(exe: &Path, appimage: Option<&Path>) -> io::Result<PathBuf> {
|
||||
let path = appimage.unwrap_or(exe);
|
||||
if !path.is_absolute() || !path.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "the OpenIDEA executable must be in a permanent location"));
|
||||
}
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
|
||||
pub fn wrapper_path(dir: &Path, windows: bool) -> PathBuf {
|
||||
dir.join(if windows { "openidea.cmd" } else { COMMAND })
|
||||
}
|
||||
|
||||
fn wrapper(exe: &Path, windows: bool) -> io::Result<String> {
|
||||
let exe =
|
||||
exe.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the executable path is not UTF-8"))?;
|
||||
if exe.contains(['\r', '\n']) || (windows && exe.contains('"')) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "the executable path contains unsupported characters"));
|
||||
}
|
||||
Ok(if windows {
|
||||
format!(
|
||||
"@echo off\r\nrem {WRAPPER_MARKER}\r\nsetlocal DisableDelayedExpansion\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"_openidea_codepage=%%c\"\r\n\"%SystemRoot%\\System32\\chcp.com\" 65001 >nul\r\n\"{}\" --cli %*\r\nset \"_openidea_exit=%errorlevel%\"\r\n\"%SystemRoot%\\System32\\chcp.com\" %_openidea_codepage% >nul\r\nexit /b %_openidea_exit%\r\n",
|
||||
exe.replace('%', "%%")
|
||||
)
|
||||
} else {
|
||||
format!("#!/bin/sh\n# {WRAPPER_MARKER}\nexec {} --cli \"$@\"\n", quote(Path::new(exe)))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_wrapper(path: &Path, windows: bool) -> bool {
|
||||
path.symlink_metadata().is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink())
|
||||
&& std::fs::read_to_string(path).is_ok_and(|text| {
|
||||
text.lines().nth(1) == Some(&format!("{} {WRAPPER_MARKER}", if windows { "rem" } else { "#" }))
|
||||
})
|
||||
}
|
||||
|
||||
/// Never follows or overwrites a foreign file, even if it has the same command name.
|
||||
pub fn install_wrapper(exe: &Path, dir: &Path, windows: bool) -> io::Result<PathBuf> {
|
||||
use std::io::Write;
|
||||
let text = wrapper(exe, windows)?;
|
||||
let path = wrapper_path(dir, windows);
|
||||
if path.symlink_metadata().is_ok() {
|
||||
if !is_wrapper(&path, windows) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} already exists and is not an OpenIDEA command", path.display()),
|
||||
));
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
file.write_all(text.as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn uninstall_wrapper(dir: &Path, windows: bool) -> io::Result<Vec<PathBuf>> {
|
||||
let path = wrapper_path(dir, windows);
|
||||
if !is_wrapper(&path, windows) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
Ok(vec![path])
|
||||
}
|
||||
|
||||
/// Preserve every unrelated PATH entry, including expandable environment variables.
|
||||
pub fn windows_path(current: &str, dir: &str, add: bool) -> Option<String> {
|
||||
fn equal(a: &str, b: &str) -> bool {
|
||||
a.trim().trim_matches('"').trim_end_matches(['\\', '/']).eq_ignore_ascii_case(b.trim_end_matches(['\\', '/']))
|
||||
}
|
||||
let found = current.split(';').any(|entry| equal(entry, dir));
|
||||
if add {
|
||||
if found {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{dir}{}{current}", if current.is_empty() { "" } else { ";" }))
|
||||
} else if found {
|
||||
Some(current.split(';').filter(|entry| !equal(entry, dir)).collect::<Vec<_>>().join(";"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn update_user_path(dir: &Path, add: bool) -> io::Result<()> {
|
||||
use winreg::{
|
||||
RegKey,
|
||||
enums::{HKEY_CURRENT_USER, REG_EXPAND_SZ},
|
||||
types::{FromRegValue, ToRegValue},
|
||||
};
|
||||
let (environment, _) = RegKey::predef(HKEY_CURRENT_USER).create_subkey("Environment")?;
|
||||
let raw = match environment.get_raw_value("Path") {
|
||||
Ok(raw) => Some(raw),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let current = raw.as_ref().map(String::from_reg_value).transpose()?.unwrap_or_default();
|
||||
let Some(updated) = windows_path(¤t, &dir.to_string_lossy(), add) else { return Ok(()) };
|
||||
let mut value = updated.to_reg_value();
|
||||
value.vtype = raw.as_ref().map_or(REG_EXPAND_SZ, |raw| raw.vtype.clone());
|
||||
environment.set_raw_value("Path", &value)?;
|
||||
let message: Vec<u16> = "Environment\0".encode_utf16().collect();
|
||||
// SAFETY: the terminated string stays alive for this bounded synchronous broadcast.
|
||||
unsafe {
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::SendMessageTimeoutW(
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::HWND_BROADCAST,
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::WM_SETTINGCHANGE,
|
||||
0,
|
||||
message.as_ptr() as isize,
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::SMTO_ABORTIFHUNG,
|
||||
1000,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Installation {
|
||||
pub path: PathBuf,
|
||||
/// Some when the user needs to add this directory to their shell's PATH.
|
||||
pub path_directory: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn resolves_to(path_var: Option<&OsStr>, command: &Path) -> bool {
|
||||
path_var
|
||||
.and_then(|path| std::env::split_paths(path).map(|dir| dir.join(COMMAND)).find(|candidate| candidate.is_file()))
|
||||
.is_some_and(|candidate| candidate == command)
|
||||
}
|
||||
|
||||
fn user_bin() -> io::Result<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
dirs::data_local_dir().map(|dir| dir.join("OpenIDEA/bin"))
|
||||
} else {
|
||||
dirs::home_dir().map(|dir| dir.join(".local/bin"))
|
||||
}
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user directory for the openidea command"))
|
||||
}
|
||||
|
||||
pub fn install_current() -> io::Result<Installation> {
|
||||
let exe = std::env::current_exe()?;
|
||||
if cfg!(target_os = "macos") {
|
||||
let launcher = bundled_launcher(&exe).ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "install the command from the packaged OpenIDEA app")
|
||||
})?;
|
||||
if translocated(&launcher) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"move OpenIDEA to the Applications folder first",
|
||||
));
|
||||
}
|
||||
let dir = link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref());
|
||||
return Ok(Installation { path: install(&launcher, &dir)?, path_directory: None });
|
||||
}
|
||||
let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None };
|
||||
let exe = stable_executable(&exe, image.as_deref())?;
|
||||
let dir = user_bin()?;
|
||||
let path = install_wrapper(&exe, &dir, cfg!(windows))?;
|
||||
#[cfg(windows)]
|
||||
update_user_path(&dir, true)?;
|
||||
let on_path = cfg!(windows) || resolves_to(std::env::var_os("PATH").as_deref(), &path);
|
||||
Ok(Installation { path, path_directory: (!on_path).then_some(dir) })
|
||||
}
|
||||
|
||||
pub fn uninstall_current() -> io::Result<Vec<PathBuf>> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let links = installed(&link_dirs(dirs::home_dir().as_deref()));
|
||||
uninstall(&links)?;
|
||||
return Ok(links);
|
||||
}
|
||||
let dir = user_bin()?;
|
||||
let path = wrapper_path(&dir, cfg!(windows));
|
||||
if is_wrapper(&path, cfg!(windows)) {
|
||||
#[cfg(windows)]
|
||||
update_user_path(&dir, false)?;
|
||||
return uninstall_wrapper(&dir, cfg!(windows));
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod wrapper_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrappers_install_update_and_remove_only_their_own_files() {
|
||||
for windows in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("App folder/程序 & it's 100%.exe");
|
||||
let path = install_wrapper(&exe, dir.path(), windows).unwrap();
|
||||
assert!(is_wrapper(&path, windows));
|
||||
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap(), path);
|
||||
assert_eq!(uninstall_wrapper(dir.path(), windows).unwrap(), vec![path.clone()]);
|
||||
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
|
||||
std::fs::write(&path, "foreign command").unwrap();
|
||||
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "foreign command");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_changes_keep_other_entries_and_do_not_duplicate_the_command_dir() {
|
||||
let dir = r"C:\Users\用户\OpenIDEA\bin";
|
||||
let initial = r"%USERPROFILE%\tools;C:\other";
|
||||
let updated = windows_path(initial, dir, true).unwrap();
|
||||
assert_eq!(updated, format!("{dir};{initial}"));
|
||||
assert_eq!(windows_path(&updated, dir, true), None);
|
||||
assert_eq!(windows_path(&updated, dir, false).as_deref(), Some(initial));
|
||||
assert_eq!(
|
||||
windows_path(&format!("\"{dir}\\\";C:\\other"), &dir.to_uppercase(), false).as_deref(),
|
||||
Some(r"C:\other")
|
||||
);
|
||||
assert_eq!(windows_path(initial, dir, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appimage_uses_the_persistent_image_not_the_mounted_binary() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let image = dir.path().join("OpenIDEA.AppImage");
|
||||
std::fs::write(&image, "image").unwrap();
|
||||
assert_eq!(stable_executable(Path::new("/tmp/.mount_App/usr/bin/openidea"), Some(&image)).unwrap(), image);
|
||||
assert_eq!(stable_executable(&image, None).unwrap(), image);
|
||||
assert!(stable_executable(Path::new("missing"), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_a_system_binary_shadowing_the_linux_wrapper() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let system = dir.path().join("system");
|
||||
let user = dir.path().join("user");
|
||||
for bin in [&system, &user] {
|
||||
std::fs::create_dir_all(bin).unwrap();
|
||||
std::fs::write(bin.join(COMMAND), "command").unwrap();
|
||||
}
|
||||
let path = std::env::join_paths([&system, &user]).unwrap();
|
||||
assert!(!resolves_to(Some(&path), &user.join(COMMAND)));
|
||||
let path = std::env::join_paths([&user, &system]).unwrap();
|
||||
assert!(resolves_to(Some(&path), &user.join(COMMAND)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn posix_wrapper_preserves_arguments_and_exit_status() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("app & it's $dollar `quoted` \"double\"");
|
||||
std::fs::write(&exe, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap();
|
||||
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path = install_wrapper(&exe, &dir.path().join("bin"), false).unwrap();
|
||||
let output = std::process::Command::new(path).args(["a b", "中文", "x'$`&"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap(), "--cli\na b\n中文\nx'$`&\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn cmd_wrapper_preserves_arguments_and_exit_status() {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("程序 & 100% !special!.exe");
|
||||
let source = dir.path().join("helper.rs");
|
||||
std::fs::write(
|
||||
&source,
|
||||
r#"fn main() { for arg in std::env::args().skip(1) { println!("{arg}"); } std::process::exit(7); }"#,
|
||||
)
|
||||
.unwrap();
|
||||
let compiled = std::process::Command::new("rustc")
|
||||
.args(["--crate-name", "launcher_test"])
|
||||
.arg(&source)
|
||||
.arg("-o")
|
||||
.arg(&exe)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(compiled.status.success(), "{}", String::from_utf8_lossy(&compiled.stderr));
|
||||
let path = install_wrapper(&exe, &dir.path().join("bin"), true).unwrap();
|
||||
std::fs::write(dir.path().join("chcp.cmd"), "@echo malicious-chcp\r\nexit /b 99\r\n").unwrap();
|
||||
let output = std::process::Command::new("cmd.exe")
|
||||
.current_dir(dir.path())
|
||||
.args(["/d", "/s", "/c"])
|
||||
.raw_arg(format!("\"\"{}\" \"a b\" \"中文\" \"x&y\"\"", path.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap().replace("\r\n", "\n"), "--cli\na b\n中文\nx&y\n");
|
||||
let check = dir.path().join("check.cmd");
|
||||
std::fs::write(&check, "@echo off\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"before=%%c\"\r\ncall \"%~dp0bin\\openidea.cmd\" example\r\nset \"code=%errorlevel%\"\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"after=%%c\"\r\nif not \"%before%\"==\"%after%\" exit /b 99\r\nexit /b %code%\r\n").unwrap();
|
||||
let output = std::process::Command::new("cmd.exe")
|
||||
.current_dir(dir.path())
|
||||
.args(["/d", "/s", "/c"])
|
||||
.raw_arg(format!("\"\"{}\"\"", check.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert!(!String::from_utf8_lossy(&output.stdout).contains("malicious-chcp"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bundle(root: &Path) -> (PathBuf, PathBuf) {
|
||||
let app = root.join("OpenIDEA.app");
|
||||
let exe = app.join("Contents/MacOS/openidea");
|
||||
let launcher = app.join(LAUNCHER);
|
||||
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
|
||||
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
|
||||
std::fs::write(&exe, "").unwrap();
|
||||
std::fs::write(&launcher, "").unwrap();
|
||||
(exe, launcher)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_launcher_of_a_bundle_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (exe, launcher) = bundle(dir.path());
|
||||
assert_eq!(bundled_launcher(&exe), Some(launcher));
|
||||
assert_eq!(bundled_launcher(&dir.path().join("target/debug/openidea")), None);
|
||||
assert!(translocated(Path::new("/private/var/folders/x/AppTranslocation/ABC/d/OpenIDEA.app")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_local_bin_on_path() {
|
||||
let home = Path::new("/Users/u");
|
||||
let path = std::env::join_paths(["/usr/bin", "/Users/u/.local/bin"]).unwrap();
|
||||
assert_eq!(link_dir(Some(&path), Some(home)), home.join(".local/bin"));
|
||||
assert_eq!(link_dir(Some(OsStr::new("/usr/bin")), Some(home)), PathBuf::from(SYSTEM_DIR));
|
||||
assert_eq!(link_dir(None, Some(home)), PathBuf::from(SYSTEM_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_replaces_its_own_link_but_not_other_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (_, launcher) = bundle(dir.path());
|
||||
let bin = dir.path().join("bin");
|
||||
let link = install(&launcher, &bin).unwrap();
|
||||
assert_eq!(std::fs::read_link(&link).unwrap(), launcher);
|
||||
assert_eq!(install(&launcher, &bin).unwrap(), link);
|
||||
assert_eq!(installed(std::slice::from_ref(&bin)), vec![link.clone()]);
|
||||
uninstall(&installed(std::slice::from_ref(&bin))).unwrap();
|
||||
assert!(installed(std::slice::from_ref(&bin)).is_empty());
|
||||
std::fs::write(&link, "someone else's").unwrap();
|
||||
assert_eq!(install(&launcher, &bin).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoting() {
|
||||
assert_eq!(quote(Path::new("/a b/it's")), r"'/a b/it'\''s'");
|
||||
assert_eq!(applescript_escape(r#"ln "a\b""#), r#"ln \"a\\b\""#);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! The `openidea` command.
|
||||
//!
|
||||
//! The command is the app's own executable run in CLI mode: it parses its
|
||||
//! arguments ([`args`]), sends one [`Request`] to the running app over a local
|
||||
//! socket ([`socket`]) and exits. [`install`] links the bundle's launcher
|
||||
//! script into a directory on `PATH`.
|
||||
|
||||
pub mod args;
|
||||
pub mod install;
|
||||
pub mod socket;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Bumped when [`Request`] changes incompatibly; an app still running an older
|
||||
/// version answers with an error instead of misreading it.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// A file or folder to open, with an optional 1-based position in a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Target {
|
||||
/// Absolute.
|
||||
pub path: PathBuf,
|
||||
pub line: Option<u32>,
|
||||
pub column: Option<u32>,
|
||||
}
|
||||
|
||||
/// What the command asks the running app to do. No targets: bring the app to the front.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub version: u32,
|
||||
pub targets: Vec<Target>,
|
||||
#[serde(default)]
|
||||
pub diff: bool,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn open(targets: Vec<Target>) -> Self {
|
||||
Self { version: PROTOCOL_VERSION, targets, diff: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Response {
|
||||
Ok,
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// A GUI subsystem executable inherits redirected handles, but needs its parent's
|
||||
/// console for interactive help and errors. Batch launchers wait for this process.
|
||||
#[cfg(windows)]
|
||||
pub fn attach_console() {
|
||||
use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType};
|
||||
use windows_sys::Win32::System::Console::{
|
||||
ATTACH_PARENT_PROCESS, AttachConsole, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
|
||||
SetStdHandle,
|
||||
};
|
||||
// SAFETY: attaches only this CLI process, without allocating a new console.
|
||||
unsafe {
|
||||
let redirected = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE].map(|kind| {
|
||||
let handle = GetStdHandle(kind);
|
||||
(kind, handle, matches!(GetFileType(handle), FILE_TYPE_DISK | FILE_TYPE_PIPE))
|
||||
});
|
||||
AttachConsole(ATTACH_PARENT_PROCESS);
|
||||
for (kind, handle, keep) in redirected {
|
||||
if keep {
|
||||
SetStdHandle(kind, handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! The local socket the running app listens on: one JSON [`Request`] line in,
|
||||
//! one [`Response`] line out. Unix domain sockets or Windows named pipes.
|
||||
|
||||
use crate::{Request, Response};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_MESSAGE: u64 = 1024 * 1024;
|
||||
|
||||
fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response {
|
||||
match serde_json::from_str::<Request>(line) {
|
||||
Ok(request) if request.version == crate::PROTOCOL_VERSION && (!request.diff || request.targets.len() == 2) => {
|
||||
handle(request)
|
||||
}
|
||||
Ok(_) => Response::Error { message: "unsupported or invalid CLI request".into() },
|
||||
Err(e) => Response::Error { message: format!("invalid request: {e}") },
|
||||
}
|
||||
}
|
||||
|
||||
/// The socket of the app with bundle identifier `identifier`. The name is a hash:
|
||||
/// socket paths are limited to about 100 bytes, and development identifiers are long.
|
||||
pub fn socket_path(identifier: &str) -> Option<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
let user = dirs::data_local_dir()?;
|
||||
return Some(PathBuf::from(format!(
|
||||
r"\\.\pipe\openidea-{:016x}",
|
||||
fnv1a(&format!("{}:{identifier}", user.display()))
|
||||
)));
|
||||
}
|
||||
let dir = if cfg!(target_os = "macos") { dirs::cache_dir() } else { dirs::runtime_dir().or_else(dirs::cache_dir) };
|
||||
Some(dir?.join(format!("openidea-{:016x}.sock", fnv1a(identifier))))
|
||||
}
|
||||
|
||||
fn fnv1a(s: &str) -> u64 {
|
||||
s.bytes().fold(0xcbf29ce484222325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
|
||||
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
|
||||
let stream = UnixStream::connect(socket)?;
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
write_line(&stream, request)?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new((&stream).take(MAX_MESSAGE)).read_line(&mut reply)?;
|
||||
Ok(serde_json::from_str(&reply)?)
|
||||
}
|
||||
|
||||
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
|
||||
let listener = bind(socket)?;
|
||||
std::thread::Builder::new().name("cli-socket".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
if let Err(e) = serve_one(&stream, &handle) {
|
||||
tracing::warn!("cli request failed: {e}");
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Binds `socket`, replacing a stale file no one answers on.
|
||||
fn bind(socket: &Path) -> io::Result<UnixListener> {
|
||||
if let Some(dir) = socket.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let listener = match UnixListener::bind(socket) {
|
||||
Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
|
||||
if !std::fs::symlink_metadata(socket)?.file_type().is_socket() || UnixStream::connect(socket).is_ok() {
|
||||
return Err(e);
|
||||
}
|
||||
std::fs::remove_file(socket)?;
|
||||
UnixListener::bind(socket)?
|
||||
}
|
||||
bound => bound?,
|
||||
};
|
||||
std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
fn serve_one(stream: &UnixStream, handle: &dyn Fn(Request) -> Response) -> io::Result<()> {
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream.take(MAX_MESSAGE)).read_line(&mut line)?;
|
||||
write_line(stream, &answer(&line, handle))
|
||||
}
|
||||
|
||||
fn write_line(mut stream: &UnixStream, value: &impl serde::Serialize) -> io::Result<()> {
|
||||
let mut line = serde_json::to_string(value)?;
|
||||
line.push('\n');
|
||||
stream.write_all(line.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions};
|
||||
|
||||
fn runtime() -> io::Result<tokio::runtime::Runtime> {
|
||||
tokio::runtime::Builder::new_current_thread().enable_all().build()
|
||||
}
|
||||
|
||||
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
|
||||
runtime()?.block_on(async {
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
let mut pipe = loop {
|
||||
match ClientOptions::new().open(socket) {
|
||||
Ok(pipe) => break pipe,
|
||||
// All existing instances are busy; wait instead of starting another app.
|
||||
Err(e) if e.raw_os_error() == Some(231) => tokio::time::sleep(Duration::from_millis(10)).await,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
};
|
||||
let mut line = serde_json::to_vec(request)?;
|
||||
line.push(b'\n');
|
||||
pipe.write_all(&line).await?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut reply).await?;
|
||||
pipe.write_all(b"\n").await?;
|
||||
Ok(serde_json::from_str(&reply)?)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "CLI request timed out"))?
|
||||
})
|
||||
}
|
||||
|
||||
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
|
||||
let socket = socket.to_owned();
|
||||
let (ready, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
std::thread::Builder::new().name("cli-pipe".into()).spawn(move || {
|
||||
let runtime = match runtime() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
runtime.block_on(async move {
|
||||
let mut pipe = match ServerOptions::new().first_pipe_instance(true).create(&socket) {
|
||||
Ok(pipe) => pipe,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = ready.send(Ok(()));
|
||||
loop {
|
||||
if let Err(e) = pipe.connect().await {
|
||||
tracing::warn!("CLI pipe connection failed: {e}");
|
||||
return;
|
||||
}
|
||||
// Keep an instance bound while answering, so another process cannot take the name.
|
||||
let next = match ServerOptions::new().create(&socket) {
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot create CLI pipe: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let reply = tokio::time::timeout(TIMEOUT, async {
|
||||
let mut line = String::new();
|
||||
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut line).await?;
|
||||
let mut reply = serde_json::to_vec(&answer(&line, &handle))?;
|
||||
reply.push(b'\n');
|
||||
pipe.write_all(&reply).await?;
|
||||
// Wait until the client has drained the reply before closing its instance.
|
||||
pipe.read_exact(&mut [0u8; 1]).await.map(|_| ())
|
||||
})
|
||||
.await;
|
||||
if !matches!(reply, Ok(Ok(()))) {
|
||||
tracing::warn!("CLI pipe request failed: {reply:?}");
|
||||
}
|
||||
// Dropping after the write lets the client drain its reply before disconnecting.
|
||||
drop(pipe);
|
||||
pipe = next;
|
||||
}
|
||||
});
|
||||
})?;
|
||||
receiver.recv().map_err(|_| io::Error::other("CLI pipe thread stopped"))?
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends `request` to the app listening on `socket` and waits for its answer.
|
||||
pub use imp::send;
|
||||
/// Answers requests on `socket` with `handle`, on a thread of its own.
|
||||
pub use imp::serve;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn socket_names_separate_identifiers_and_stay_short() {
|
||||
let release = socket_path("ai.l0ng.openidea").unwrap();
|
||||
let dev = socket_path("ai.l0ng.openidea.dev.w158e9c5954b7bf64").unwrap();
|
||||
assert_ne!(release, dev);
|
||||
assert!(dev.as_os_str().len() < 100 || cfg!(unix));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_only_its_protocol_version() {
|
||||
let ok = |_: Request| Response::Ok;
|
||||
assert_eq!(answer(&serde_json::to_string(&Request::open(vec![])).unwrap(), &ok), Response::Ok);
|
||||
let old = r#"{"version":0,"targets":[]}"#;
|
||||
assert!(matches!(answer(old, &ok), Response::Error { .. }));
|
||||
assert!(matches!(answer("garbage", &ok), Response::Error { .. }));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn named_pipe_round_trip() {
|
||||
let socket = socket_path(&format!("test-{}", std::process::id())).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
serve(&socket, move |request| {
|
||||
tx.send(request).unwrap();
|
||||
Response::Ok
|
||||
})
|
||||
.unwrap();
|
||||
for _ in 0..2 {
|
||||
let target = crate::Target { path: r"C:\项目\my file.rs".into(), line: Some(3), column: Some(2) };
|
||||
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
|
||||
assert_eq!(rx.recv_timeout(TIMEOUT).unwrap().targets, vec![target]);
|
||||
}
|
||||
assert!(serve(&socket, |_| Response::Ok).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn request_round_trip_and_stale_socket() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("cli.sock");
|
||||
// A file left by an app that exited without removing it.
|
||||
drop(std::os::unix::net::UnixListener::bind(&socket).unwrap());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
serve(&socket, move |request| {
|
||||
tx.send(request).unwrap();
|
||||
Response::Ok
|
||||
})
|
||||
.unwrap();
|
||||
let target = crate::Target { path: "/p/a.rs".into(), line: Some(3), column: None };
|
||||
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
|
||||
assert_eq!(rx.recv().unwrap().targets, vec![target]);
|
||||
// A live server keeps its socket.
|
||||
assert_eq!(serve(&socket, |_| Response::Ok).unwrap_err().kind(), io::ErrorKind::AddrInUse);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn refuses_to_remove_an_unrelated_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("cli.sock");
|
||||
std::fs::write(&socket, "keep").unwrap();
|
||||
assert!(serve(&socket, |_| Response::Ok).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&socket).unwrap(), "keep");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -915,6 +926,8 @@ pub struct MavenSettings {
|
||||
pub home: String,
|
||||
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
|
||||
pub user_settings: String,
|
||||
/// Toolchains file (`-t`); empty = `~/.m2/toolchains.xml`.
|
||||
pub toolchains: String,
|
||||
/// Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`.
|
||||
pub local_repository: String,
|
||||
/// Run Maven through the Maven Daemon (`mvnd`) for faster builds. mvnd embeds
|
||||
@@ -926,9 +939,25 @@ pub struct MavenSettings {
|
||||
/// Thread count of Maven builds (`-T`): a number of threads (`4`) or a core
|
||||
/// multiplier (`2C`, `2.5C`); empty = Maven's default (one thread per module).
|
||||
pub threads: String,
|
||||
/// Active Maven profiles (`-P`), comma-separated; empty leaves profiles to the POM/settings.
|
||||
pub active_profiles: String,
|
||||
}
|
||||
|
||||
impl MavenSettings {
|
||||
/// Settings profiles followed by the Maven tool window's choices.
|
||||
pub fn profiles(&self, choices: &[String]) -> Vec<String> {
|
||||
let mut profiles = Vec::new();
|
||||
for profile in self.active_profiles.split(',').chain(choices.iter().map(String::as_str)).map(str::trim) {
|
||||
if !profile.is_empty() && !profiles.iter().any(|p| p == profile) {
|
||||
profiles.push(profile.to_owned());
|
||||
}
|
||||
}
|
||||
let excluded: Vec<String> =
|
||||
profiles.iter().filter_map(|p| p.strip_prefix(['!', '-']).map(str::to_owned)).collect();
|
||||
profiles.retain(|p| !excluded.contains(p));
|
||||
profiles
|
||||
}
|
||||
|
||||
/// The `-T` value when it is one Maven accepts: a thread count (`4`) or a
|
||||
/// core multiplier with an optional decimal fraction (`1C`, `2.5C`).
|
||||
/// Anything else is dropped: a bad `-T` would make every Maven run fail.
|
||||
@@ -1388,6 +1417,24 @@ mod maven_linkage_tests {
|
||||
assert_eq!(threads("2..5C").as_deref(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_profiles_combine_settings_and_tool_window_choices() {
|
||||
let settings = MavenSettings { active_profiles: " a, b, ,a ".into(), ..Default::default() };
|
||||
assert_eq!(settings.profiles(&["!b".into(), "c".into(), "a".into()]), ["a", "!b", "c"]);
|
||||
let settings = MavenSettings { active_profiles: "!a".into(), ..Default::default() };
|
||||
assert_eq!(settings.profiles(&["a".into()]), ["!a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_profiles_and_toolchains_round_trip_and_default_to_empty() {
|
||||
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
|
||||
assert!(old.toolchains.is_empty() && old.active_profiles.is_empty());
|
||||
let settings =
|
||||
MavenSettings { toolchains: "config files/toolchains.xml".into(), active_profiles: "a,b,!c".into(), ..old };
|
||||
let encoded = serde_json::to_string(&settings).unwrap();
|
||||
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_count_round_trips_and_defaults_to_empty() {
|
||||
// Old settings files predate the thread count; `serde(default)` keeps them loadable.
|
||||
|
||||
@@ -101,6 +101,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"lldb-dap" => {
|
||||
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
|
||||
}
|
||||
"rdbg" => cfg("rdbg", &["-O", "--port", PORT_PLACEHOLDER, "-c"], "tcp", "gem install debug"),
|
||||
// A jdtls bundle: the session is started through jdtls and reached via `debugServer`.
|
||||
"java-debug" => cfg(
|
||||
"com.microsoft.java.debug.plugin.jar",
|
||||
|
||||
@@ -305,6 +305,51 @@ pub struct DebugManager {
|
||||
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
|
||||
}
|
||||
|
||||
/// rdbg (Ruby's debug gem) starts the debuggee itself and ignores `launch`'s program,
|
||||
/// arguments and environment: they go on its command line after `--` and into its process.
|
||||
/// In a Bundler project it runs from the bundle, which must have `debug`: the debuggee then
|
||||
/// loads that version, and the gems rdbg needs (irb is not a default gem from Ruby 3.5).
|
||||
fn rdbg_command_line(spec: &mut AdapterSpec, config: &LaunchConfig, cwd: &Path) -> IdeResult<()> {
|
||||
if spec.command == "rdbg"
|
||||
&& let Some(gemfile) = bundler_gemfile(cwd, &config.env)
|
||||
{
|
||||
if !bundles_debug(&gemfile) {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"the bundle of {} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`",
|
||||
gemfile.display()
|
||||
)));
|
||||
}
|
||||
spec.args.splice(0..0, ["exec".to_owned(), "rdbg".to_owned()]);
|
||||
spec.command = "bundle".into();
|
||||
}
|
||||
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
spec.args.push("--".into());
|
||||
spec.args.extend(config.program.iter().cloned());
|
||||
spec.args.extend(config.args.iter().cloned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Gemfile Bundler uses in `dir`: `BUNDLE_GEMFILE`, else the closest `Gemfile` / `gems.rb` from `dir` up.
|
||||
fn bundler_gemfile(dir: &Path, env: &BTreeMap<String, String>) -> Option<PathBuf> {
|
||||
match env.get("BUNDLE_GEMFILE").filter(|g| !g.is_empty()) {
|
||||
Some(gemfile) => Some(dir.join(gemfile)),
|
||||
None => dir.ancestors().flat_map(|d| [d.join("Gemfile"), d.join("gems.rb")]).find(|g| g.is_file()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the lockfile of `gemfile` lists the `debug` gem.
|
||||
fn bundles_debug(gemfile: &Path) -> bool {
|
||||
let lockfile = if gemfile.file_name().is_some_and(|n| n == "gems.rb") {
|
||||
gemfile.with_file_name("gems.locked")
|
||||
} else {
|
||||
let mut name = gemfile.as_os_str().to_owned();
|
||||
name.push(".lock");
|
||||
PathBuf::from(name)
|
||||
};
|
||||
// The bundle's gems are the lines indented by four spaces: ` debug (1.9.2)`.
|
||||
std::fs::read_to_string(lockfile).is_ok_and(|text| text.lines().any(|l| l.starts_with(" debug (")))
|
||||
}
|
||||
|
||||
/// Whether the debugged project depends on the Flutter SDK, so it must go
|
||||
/// through `flutter debug_adapter` instead of the plain Dart one.
|
||||
///
|
||||
@@ -651,6 +696,9 @@ impl DebugManager {
|
||||
{
|
||||
spec.env.push(("JAVA_HOME".into(), home));
|
||||
}
|
||||
if config.adapter == "rdbg" {
|
||||
rdbg_command_line(&mut spec, &config, &cwd)?;
|
||||
}
|
||||
if let Some(command) = custom_command(&spec.command) {
|
||||
spec.command = command;
|
||||
}
|
||||
|
||||
@@ -1391,6 +1391,71 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdbg_runs_the_debuggee_after_its_options_with_the_configuration_environment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
|
||||
let config = LaunchConfig {
|
||||
adapter: "rdbg".into(),
|
||||
program: Some("ruby".into()),
|
||||
args: ["-Ilib", "app.rb", "--verbose"].map(String::from).to_vec(),
|
||||
env: BTreeMap::from([("RAILS_ENV".into(), "test".into())]),
|
||||
extra_json: None,
|
||||
..config()
|
||||
};
|
||||
let mut spec = adapter::resolve(&rdbg, dir.path().into());
|
||||
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
|
||||
assert_eq!(spec.command, "rdbg");
|
||||
assert_eq!(spec.args, ["-O", "--port", "${port}", "-c", "--", "ruby", "-Ilib", "app.rb", "--verbose"]);
|
||||
assert!(spec.env.contains(&("RAILS_ENV".into(), "test".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdbg_runs_from_a_bundle_that_has_debug_and_refuses_one_without() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = dir.path().join("app");
|
||||
std::fs::create_dir_all(app.join("lib")).unwrap();
|
||||
std::fs::write(app.join("Gemfile"), "source \"https://rubygems.org\"\n").unwrap();
|
||||
// A dependency of another gem is not a gem of the bundle.
|
||||
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n irb (1.14.0)\n debug (>= 1)\n").unwrap();
|
||||
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
|
||||
let config = LaunchConfig {
|
||||
adapter: "rdbg".into(),
|
||||
program: Some("bundle".into()),
|
||||
args: ["exec", "rspec"].map(String::from).to_vec(),
|
||||
extra_json: None,
|
||||
..config()
|
||||
};
|
||||
let mut spec = adapter::resolve(&rdbg, app.clone());
|
||||
let err = rdbg_command_line(&mut spec, &config, &app).unwrap_err();
|
||||
assert!(err.message.contains("gem \"debug\""), "{}", err.message);
|
||||
|
||||
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n debug (1.9.2)\n irb (~> 1.10)\n").unwrap();
|
||||
// From a subdirectory too: Bundler finds the Gemfile above it.
|
||||
for cwd in [app.clone(), app.join("lib")] {
|
||||
let mut spec = adapter::resolve(&rdbg, cwd.clone());
|
||||
rdbg_command_line(&mut spec, &config, &cwd).unwrap();
|
||||
assert_eq!(spec.command, "bundle");
|
||||
assert_eq!(spec.args, ["exec", "rdbg", "-O", "--port", "${port}", "-c", "--", "bundle", "exec", "rspec"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundler_gemfile_and_lockfile_follow_bundle_gemfile_and_gems_rb() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("gems")).unwrap();
|
||||
let none = BTreeMap::new();
|
||||
assert_eq!(bundler_gemfile(root, &none), None);
|
||||
std::fs::write(root.join("gems/gems.rb"), "").unwrap();
|
||||
std::fs::write(root.join("gems/gems.locked"), "GEM\n specs:\n debug (1.9.2)\n").unwrap();
|
||||
let gems_rb = bundler_gemfile(&root.join("gems"), &none).unwrap();
|
||||
assert_eq!(gems_rb, root.join("gems/gems.rb"));
|
||||
assert!(bundles_debug(&gems_rb));
|
||||
let env = BTreeMap::from([("BUNDLE_GEMFILE".into(), "gems/gems.rb".into())]);
|
||||
assert_eq!(bundler_gemfile(root, &env), Some(gems_rb));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stop_waits_for_the_launched_program_to_exit() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, &[])]));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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('\'', "'\\''")) }
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)) = ¤t 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"));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(|_| ())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -75,8 +75,20 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
config.extra_json = Some(extra.to_string());
|
||||
}
|
||||
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
let mut ruby_tools = Vec::new();
|
||||
if config.adapter == "rdbg" {
|
||||
let project_root = host.project(window).map(|p| p.root.clone()).unwrap_or_else(|_| root.clone());
|
||||
let (dir, settings) = (root.clone(), settings.clone());
|
||||
(config, ruby_tools) = host
|
||||
.blocking(move || {
|
||||
let tools = ide_sdk::ruby::debug_environment(&project_root, &dir, &settings, &mut config.env);
|
||||
Ok((config, tools))
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
let custom = |command: &str| {
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)
|
||||
.or_else(|| ruby_tools.into_iter().find(|(name, _)| *name == command).map(|(_, path)| path))?;
|
||||
Some(path.to_string_lossy().into_owned())
|
||||
};
|
||||
host.require_trust(window)?;
|
||||
|
||||
@@ -235,11 +235,18 @@ pub async fn java_build_workspace(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_build(&module, &CancellationToken::new()).await
|
||||
// HotSwap may cancel the compile (the request id is allocated by the caller).
|
||||
let cancel = request.map_or_else(CancellationToken::new, |id| host.begin_request(id));
|
||||
let result = project.lsp.java_build(&module, &cancel).await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
@@ -249,7 +256,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
.await
|
||||
});
|
||||
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
|
||||
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
|
||||
d.add("java_build_workspace", |c| async move {
|
||||
java_build_workspace(&c.host, &c.window, c.arg("module")?, c.arg("request")?).await
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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(¤t, &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.
|
||||
|
||||
@@ -24,3 +24,37 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
|
||||
fn check_worktree_removal(project_root: &std::path::Path, path: &std::path::Path) -> IdeResult<()> {
|
||||
fn canonical_path(path: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
|
||||
let mut existing = path.to_path_buf();
|
||||
let mut tail = Vec::new();
|
||||
let mut real = loop {
|
||||
match dunce::canonicalize(&existing) {
|
||||
Ok(real) => break real,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
let Some(part) = existing.components().next_back() else { return Err(e) };
|
||||
tail.push(part.as_os_str().to_owned());
|
||||
if !existing.pop() {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
};
|
||||
// The project or worktree may already be gone; resolve its missing tail too.
|
||||
for part in tail.into_iter().rev() {
|
||||
if part == ".." {
|
||||
real.pop();
|
||||
} else if part != "." {
|
||||
real.push(part);
|
||||
}
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
if canonical_path(project_root)?.starts_with(canonical_path(path)?) {
|
||||
return Err(ide_core::IdeError::invalid("Cannot remove a worktree containing the open project."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1041,6 +1041,12 @@ pub async fn vcs_remove_worktree(
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let path = if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
project.with_repo_at(repo.as_deref(), |r| Ok(r.root().join(&path)))?
|
||||
};
|
||||
check_worktree_removal(&project.root, &path)?;
|
||||
let result = project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force));
|
||||
project.refresh_repositories();
|
||||
result
|
||||
|
||||
@@ -214,6 +214,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
cmd,
|
||||
"app_info"
|
||||
| "app_build_info"
|
||||
| "app_cli_install"
|
||||
| "app_cli_uninstall"
|
||||
| "app_log_location"
|
||||
| "app_check_update"
|
||||
| "app_startup_update_check"
|
||||
@@ -277,6 +279,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "project_rename"
|
||||
| "light_edit_open"
|
||||
| "workspace_create"
|
||||
| "workspace_discover_projects"
|
||||
| "workspace_update"
|
||||
| "workspace_attach"
|
||||
| "cancel_request"
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -32,7 +32,10 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
assert!(!host.open_project("main", &root).unwrap().is_trusted());
|
||||
for command in [
|
||||
"app_info",
|
||||
"workspace_discover_projects",
|
||||
"app_build_info",
|
||||
"app_cli_install",
|
||||
"app_cli_uninstall",
|
||||
"app_log_location",
|
||||
"app_check_update",
|
||||
"app_startup_update_check",
|
||||
|
||||
@@ -91,3 +91,109 @@ async fn watcher_picks_up_a_worktree_added_below_the_project() {
|
||||
let roots = f.project.repos().roots();
|
||||
assert!(roots.contains(&linked), "watcher did not discover the worktree added below the project: {roots:?}");
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
async fn open(&self, root: &std::path::Path) {
|
||||
self.host.trust.decide(root, true, false).unwrap();
|
||||
let project = self.host.open_project("main", root).unwrap();
|
||||
wait_for_repos(&project, 1).await;
|
||||
}
|
||||
|
||||
fn linked(&self, path: &std::path::Path, branch: &str) {
|
||||
git(&self.root, &["worktree", "add", "-b", branch, path.to_str().unwrap()]);
|
||||
}
|
||||
|
||||
async fn assert_protected(&self, path: &std::path::Path) {
|
||||
for force in [false, true] {
|
||||
let error =
|
||||
vcs::vcs_remove_worktree(&self.host, "main", None, path.to_path_buf(), force).await.unwrap_err();
|
||||
assert_eq!(error.message, "Cannot remove a worktree containing the open project.");
|
||||
assert!(path.join("file.txt").exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cannot_remove_the_open_linked_worktree_or_its_ancestor() {
|
||||
let f = Fixture::new().await;
|
||||
let linked = f.root.with_file_name("front");
|
||||
f.linked(&linked, "front");
|
||||
let nested = linked.join("src");
|
||||
std::fs::create_dir(&nested).unwrap();
|
||||
std::fs::write(linked.join("file.txt"), "uncommitted changes\n").unwrap();
|
||||
for root in [&linked, &nested] {
|
||||
f.open(root).await;
|
||||
f.assert_protected(&linked).await;
|
||||
assert_eq!(std::fs::read_to_string(linked.join("file.txt")).unwrap(), "uncommitted changes\n");
|
||||
// Relative paths must be judged from the same repository as git uses.
|
||||
let error = vcs::vcs_remove_worktree(&f.host, "main", None, ".".into(), true).await.unwrap_err();
|
||||
assert_eq!(error.message, "Cannot remove a worktree containing the open project.");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn sibling_with_a_shared_prefix_and_worktrees_below_the_project_stay_removable() {
|
||||
for force in [false, true] {
|
||||
let f = Fixture::new().await;
|
||||
let linked = f.root.with_file_name("front");
|
||||
let sibling = f.root.with_file_name("front-wt");
|
||||
f.linked(&linked, "front");
|
||||
f.linked(&sibling, "sibling");
|
||||
f.open(&sibling).await;
|
||||
vcs::vcs_remove_worktree(&f.host, "main", None, linked.clone(), force).await.unwrap();
|
||||
assert!(!linked.exists());
|
||||
assert!(sibling.exists());
|
||||
|
||||
let below = sibling.join("below");
|
||||
f.linked(&below, "below");
|
||||
vcs::vcs_remove_worktree(&f.host, "main", Some(sibling.clone()), below.clone(), force).await.unwrap();
|
||||
assert!(!below.exists());
|
||||
|
||||
let parent = f.root.parent().unwrap();
|
||||
f.open(parent).await;
|
||||
vcs::vcs_remove_worktree(&f.host, "main", Some(f.root.clone()), sibling.clone(), force).await.unwrap();
|
||||
assert!(!sibling.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn symlinked_project_root_is_protected() {
|
||||
let f = Fixture::new().await;
|
||||
let linked = f.root.with_file_name("front");
|
||||
f.linked(&linked, "front");
|
||||
let alias = f.root.with_file_name("alias");
|
||||
std::os::unix::fs::symlink(&linked, &alias).unwrap();
|
||||
f.open(&alias).await;
|
||||
f.assert_protected(&linked).await;
|
||||
f.assert_protected(&alias).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn missing_project_and_worktree_paths_still_use_canonical_ancestors() {
|
||||
let f = Fixture::new().await;
|
||||
let linked = f.root.with_file_name("front");
|
||||
f.linked(&linked, "front");
|
||||
let nested = linked.join("src");
|
||||
std::fs::create_dir(&nested).unwrap();
|
||||
f.open(&nested).await;
|
||||
std::fs::remove_dir(&nested).unwrap();
|
||||
f.assert_protected(&linked).await;
|
||||
std::fs::remove_dir_all(&linked).unwrap();
|
||||
for force in [false, true] {
|
||||
let error =
|
||||
vcs::vcs_remove_worktree(&f.host, "main", Some(f.root.clone()), linked.clone(), force).await.unwrap_err();
|
||||
assert_eq!(error.message, "Cannot remove a worktree containing the open project.");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn tmp_alias_is_protected() {
|
||||
let f = Fixture::new().await;
|
||||
let linked = f.root.with_file_name("front");
|
||||
f.linked(&linked, "front");
|
||||
f.open(&linked).await;
|
||||
let alias = std::path::Path::new("/").join(linked.strip_prefix("/private").unwrap());
|
||||
f.assert_protected(&alias).await;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
(class_declaration name: (identifier) @type)
|
||||
(object_declaration name: (identifier) @type)
|
||||
(companion_object name: (identifier) @type)
|
||||
(type_alias type: (identifier) @type)
|
||||
(user_type (identifier) @type)
|
||||
(type_parameter (identifier) @type)
|
||||
(function_declaration name: (identifier) @function.definition)
|
||||
(parameter (identifier) @parameter.kotlin)
|
||||
(class_parameter (identifier) @parameter.kotlin ":")
|
||||
(property_declaration (variable_declaration (identifier) @variable))
|
||||
(class_body (property_declaration (variable_declaration (identifier) @property)))
|
||||
(class_parameter ["val" "var"] (identifier) @property ":")
|
||||
(enum_entry (identifier) @constant)
|
||||
(annotation (user_type (identifier) @attribute))
|
||||
(annotation (constructor_invocation (user_type (identifier) @attribute)))
|
||||
(call_expression . (identifier) @function)
|
||||
(call_expression . (navigation_expression (identifier) @function .))
|
||||
(class_modifier) @keyword
|
||||
(block_comment) @comment
|
||||
((block_comment) @comment.documentation (#match? @comment.documentation "^/\\*\\*"))
|
||||
(escape_sequence) @string.escape
|
||||
@@ -692,6 +692,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laravel_lsp_serves_php_files_only_below_artisan() {
|
||||
let reg = reg();
|
||||
let cfg = reg.get(&LanguageId::new("php")).unwrap();
|
||||
let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect();
|
||||
// After the fallback: hover, rename and symbols stay with Intelephense.
|
||||
assert_eq!(names, ["intelephense", "phpactor", "laravel-lsp"]);
|
||||
let lsp = cfg.language_servers.iter().find(|s| s.name == "laravel-lsp").unwrap();
|
||||
assert_eq!(lsp.command.as_str(), "laravel-lsp");
|
||||
assert!(lsp.require_root_marker && !lsp.fallback);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
for dir in ["shop", "lib"] {
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("shop/artisan"), "").unwrap();
|
||||
let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &lsp.root_markers);
|
||||
assert_eq!(served("shop/app/Models/Order.php"), Some(root.join("shop")));
|
||||
assert_eq!(served("lib/src/Parser.php"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruff_serves_python_files_only_below_its_config() {
|
||||
let reg = reg();
|
||||
@@ -963,6 +984,20 @@ mod tests {
|
||||
assert_eq!(res.line_comment.as_deref(), Some(";"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_enables_semantic_tokens_when_the_server_initializes() {
|
||||
let reg = reg();
|
||||
let go = reg.get(&LanguageId::new("go")).unwrap();
|
||||
assert_eq!(
|
||||
go.language_servers[0]
|
||||
.initialization_options
|
||||
.as_ref()
|
||||
.and_then(|options| options.get("semanticTokens"))
|
||||
.and_then(toml::Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn astro_server_reads_the_project_typescript() {
|
||||
let reg = reg();
|
||||
|
||||
@@ -120,7 +120,7 @@ pub(crate) static SPECS: &[Spec] = &[
|
||||
name: "kotlin",
|
||||
language: tree_sitter_kotlin_ng::LANGUAGE,
|
||||
outline: outline!("kotlin"),
|
||||
highlights: None,
|
||||
highlights: Some(Highlights { query: highlights!("kotlin"), first_wins: false }),
|
||||
fold_nodes: &["string_literal"],
|
||||
fold_bodies: NONE,
|
||||
imports: &["import"],
|
||||
|
||||
@@ -42,11 +42,12 @@ pub enum SyntaxHighlightKind {
|
||||
Tag,
|
||||
Operator,
|
||||
Punctuation,
|
||||
Parameter,
|
||||
}
|
||||
|
||||
impl SyntaxHighlightKind {
|
||||
/// Every kind, in `as u32` order: the legend for [`SyntaxHighlights::data`].
|
||||
pub const ALL: [Self; 24] = [
|
||||
pub const ALL: [Self; 25] = [
|
||||
Self::Keyword,
|
||||
Self::String,
|
||||
Self::StringSpecial,
|
||||
@@ -71,6 +72,7 @@ impl SyntaxHighlightKind {
|
||||
Self::Tag,
|
||||
Self::Operator,
|
||||
Self::Punctuation,
|
||||
Self::Parameter,
|
||||
];
|
||||
|
||||
/// The kebab-case name plugins use (`doc-comment`, `function-call`, …).
|
||||
@@ -162,6 +164,7 @@ const CAPTURES: &[(&str, Paint)] = {
|
||||
("variable.member", Kind(K::Property)),
|
||||
("variable.field", Kind(K::Property)),
|
||||
("variable", Kind(K::Variable)),
|
||||
("parameter.kotlin", Kind(K::Parameter)),
|
||||
("parameter", Kind(K::Variable)),
|
||||
("property", Kind(K::Property)),
|
||||
("field", Kind(K::Property)),
|
||||
@@ -361,6 +364,9 @@ mod tests {
|
||||
assert_eq!(paint_for("string.special.path"), Some(Paint::Kind(K::StringSpecial)));
|
||||
assert_eq!(paint_for("comment.doc.__attribute__"), Some(Paint::Kind(K::DocComment)));
|
||||
assert_eq!(paint_for("punctuation.bracket"), Some(Paint::Kind(K::Punctuation)));
|
||||
assert_eq!(paint_for("parameter.kotlin"), Some(Paint::Kind(K::Parameter)));
|
||||
assert_eq!(paint_for("parameter"), Some(Paint::Kind(K::Variable)));
|
||||
assert_eq!(paint_for("variable.parameter"), Some(Paint::Kind(K::Variable)));
|
||||
assert_eq!(paint_for("embedded"), Some(Paint::Clear));
|
||||
assert_eq!(paint_for("__name__"), None);
|
||||
assert_eq!(paint_for("spell"), None);
|
||||
@@ -376,6 +382,59 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kotlin_supplements_stream_highlighting_with_declaration_roles() {
|
||||
use SyntaxHighlightKind as K;
|
||||
let src = "package palette\n/** Docs */\n@Deprecated(\"old\")\nclass Greeter(val name: String) {\n var count: Int = 0\n fun greet(prefix: String): String { val message = \"hello\\n\"; println(message); return prefix }\n}\ntypealias Handler = (String) -> Unit\nvalue class UserId(val value: String)\n";
|
||||
let got = spans("kotlin", src);
|
||||
for (text, expected) in [
|
||||
("Deprecated", K::Attribute),
|
||||
("Greeter", K::Type),
|
||||
("name", K::Property),
|
||||
("count", K::Property),
|
||||
("greet", K::Function),
|
||||
("prefix", K::Parameter),
|
||||
("message", K::Variable),
|
||||
("println", K::FunctionCall),
|
||||
("Handler", K::Type),
|
||||
("value", K::Keyword),
|
||||
("/** Docs */", K::DocComment),
|
||||
("\\n", K::Escape),
|
||||
] {
|
||||
assert!(
|
||||
got.iter().any(|(word, kind)| word == text && *kind == expected),
|
||||
"missing {text}: {expected:?} in {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_kotlin_parameters_keep_variable_highlighting() {
|
||||
for (lang, src) in [
|
||||
("graphql", "query Hero { hero(id: 1) { name } }"),
|
||||
("gdscript", "func greet(id: int):\n\tpass\n"),
|
||||
("zig", "fn greet(id: u32) void {}"),
|
||||
("nix", "{ id }: id"),
|
||||
] {
|
||||
let got = spans(lang, src);
|
||||
assert!(
|
||||
got.iter().any(|(text, kind)| text == "id" && *kind == SyntaxHighlightKind::Variable),
|
||||
"{lang}: {got:?}"
|
||||
);
|
||||
assert!(!got.iter().any(|(_, kind)| *kind == SyntaxHighlightKind::Parameter), "{lang}: {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kotlin_constructor_defaults_are_not_declarations() {
|
||||
let got = spans("kotlin", "class Example(val name: String = fallback, count: Int = defaultCount)");
|
||||
assert!(got.iter().any(|(text, kind)| text == "name" && *kind == SyntaxHighlightKind::Property));
|
||||
assert!(got.iter().any(|(text, kind)| text == "count" && *kind == SyntaxHighlightKind::Parameter));
|
||||
for reference in ["fallback", "defaultCount"] {
|
||||
assert!(!got.iter().any(|(text, _)| text == reference), "{got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlights_elixir() {
|
||||
use SyntaxHighlightKind as K;
|
||||
|
||||
+240
-17
@@ -351,6 +351,115 @@ pub fn compile_errors(
|
||||
(errors, count)
|
||||
}
|
||||
|
||||
fn launch_path(path: &Path) -> PathBuf {
|
||||
// Resolve existing ancestors too: diagnostics may name a file that was just deleted.
|
||||
let resolved = path.ancestors().find_map(|ancestor| {
|
||||
dunce::canonicalize(ancestor).ok().map(|root| root.join(path.strip_prefix(ancestor).unwrap()))
|
||||
});
|
||||
let mut path = resolved.as_deref().unwrap_or(path).to_string_lossy().replace('\\', "/");
|
||||
if path.as_bytes().get(1) == Some(&b':') {
|
||||
path[..1].make_ascii_lowercase();
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
/// The runtime classpath identifies the launched module's dependencies: a directory
|
||||
/// filter alone either drops their errors or includes unrelated reactor modules.
|
||||
struct LaunchScope {
|
||||
module: PathBuf,
|
||||
/// None means the dependency scope could not be resolved; retain all errors.
|
||||
roots: Option<Vec<PathBuf>>,
|
||||
test: bool,
|
||||
}
|
||||
|
||||
impl LaunchScope {
|
||||
fn new(module: &Path, test: bool, cp: Option<&JavaClasspath>) -> Self {
|
||||
let started = Instant::now();
|
||||
let module = launch_path(module);
|
||||
let roots = cp.and_then(|cp| {
|
||||
let mut roots = vec![module.clone()];
|
||||
let mut has_output = false;
|
||||
let mut projects = std::collections::HashMap::<PathBuf, Option<PathBuf>>::new();
|
||||
for entry in cp.class_paths.iter().chain(&cp.module_paths) {
|
||||
let path = project_path(entry).unwrap_or_else(|| PathBuf::from(entry));
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("jar") || ext.eq_ignore_ascii_case("zip"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let path = launch_path(&path);
|
||||
let root = path
|
||||
.ancestors()
|
||||
.find_map(|ancestor| {
|
||||
let relative = path.strip_prefix(ancestor).ok()?;
|
||||
let output = match ancestor.file_name()?.to_str()? {
|
||||
"target" => relative == Path::new("classes") || relative == Path::new("test-classes"),
|
||||
"build" => relative.starts_with("classes") || relative.starts_with("resources"),
|
||||
_ => false,
|
||||
};
|
||||
output.then(|| ancestor.parent().map(Path::to_path_buf)).flatten()
|
||||
})
|
||||
.or_else(|| {
|
||||
let mut visited = Vec::new();
|
||||
let mut project = None;
|
||||
for ancestor in path.ancestors() {
|
||||
if let Some(cached) = projects.get(ancestor) {
|
||||
project = cached.clone();
|
||||
break;
|
||||
}
|
||||
visited.push(ancestor.to_path_buf());
|
||||
if ["pom.xml", "build.gradle", "build.gradle.kts", ".project"]
|
||||
.iter()
|
||||
.any(|name| ancestor.join(name).is_file())
|
||||
{
|
||||
project = Some(ancestor.to_path_buf());
|
||||
break;
|
||||
}
|
||||
}
|
||||
for ancestor in visited {
|
||||
projects.insert(ancestor, project.clone());
|
||||
}
|
||||
project
|
||||
})?;
|
||||
has_output = true;
|
||||
if !roots.contains(&root) {
|
||||
roots.push(root);
|
||||
}
|
||||
}
|
||||
has_output.then_some(roots)
|
||||
});
|
||||
tracing::debug!(
|
||||
elapsed_us = started.elapsed().as_micros(),
|
||||
scoped = roots.is_some(),
|
||||
"Java launch diagnostic scope"
|
||||
);
|
||||
Self { module, roots, test }
|
||||
}
|
||||
|
||||
fn includes(&self, path: &Path) -> bool {
|
||||
let Some(roots) = &self.roots else { return true };
|
||||
let path = launch_path(path);
|
||||
let Some(root) =
|
||||
roots.iter().filter(|root| path.starts_with(root)).max_by_key(|root| root.components().count())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let relative = path.strip_prefix(root).unwrap();
|
||||
let parts: Vec<_> = relative.components().collect();
|
||||
let test_source = parts.windows(2).any(|pair| pair[0].as_os_str() == "src" && pair[1].as_os_str() == "test");
|
||||
!test_source || (self.test && *root == self.module)
|
||||
}
|
||||
}
|
||||
|
||||
/// JDTLS reports all imported projects; report errors for the launch and its dependencies.
|
||||
fn launch_compile_errors(
|
||||
scope: &LaunchScope,
|
||||
diagnostics: Vec<(PathBuf, lsp_types::Diagnostic)>,
|
||||
) -> (Vec<JavaCompileError>, u32) {
|
||||
compile_errors(diagnostics.into_iter().filter(|(path, _)| scope.includes(path)).collect(), MAX_LAUNCH_ERRORS)
|
||||
}
|
||||
|
||||
/// `java.project.getClasspaths` result.
|
||||
pub fn parse_classpaths(v: &Value) -> JavaClasspath {
|
||||
let strings = |key: &str| -> Vec<String> {
|
||||
@@ -740,12 +849,6 @@ impl LspManager {
|
||||
if build == Some(JavaBuildStatus::Cancelled) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let (errors, error_count) = match build {
|
||||
Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed) => {
|
||||
cancellable_wait(cancel, self.java_compile_errors(module)).await?
|
||||
}
|
||||
_ => (Vec::new(), 0),
|
||||
};
|
||||
let (cp, java_home) = {
|
||||
let (server, _) = self.jdtls(module, cancel).await?;
|
||||
let _lock = if build_lock_held {
|
||||
@@ -753,7 +856,15 @@ impl LspManager {
|
||||
} else {
|
||||
Some(cancellable_wait(cancel, server.java_build_lock.lock()).await?)
|
||||
};
|
||||
let cp = self.java_classpath(module, test, cancel).await?;
|
||||
let cp = match self.java_classpath(module, test, cancel).await {
|
||||
Ok(cp) => Some(cp),
|
||||
Err(e) if e.kind == ErrorKind::Cancelled => return Err(e),
|
||||
Err(e) if matches!(build, Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed)) => {
|
||||
tracing::debug!("java.project.getClasspaths: {e}; retaining all compile errors");
|
||||
None
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let java_home = match self.java_vm_location(module, cancel).await {
|
||||
Ok(home) => home,
|
||||
Err(e) if e.kind == ErrorKind::Cancelled => return Err(e),
|
||||
@@ -764,6 +875,25 @@ impl LspManager {
|
||||
};
|
||||
(cp, java_home)
|
||||
};
|
||||
let (errors, error_count, scoped) = match build {
|
||||
Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed) => {
|
||||
let scope_module = module.to_path_buf();
|
||||
let cp = cp.clone();
|
||||
let scope = tokio::task::spawn_blocking(move || LaunchScope::new(&scope_module, test, cp.as_ref()))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let scoped = scope.roots.is_some();
|
||||
let (errors, count) = cancellable_wait(cancel, self.java_compile_errors(module, scope)).await??;
|
||||
(errors, count, scoped)
|
||||
}
|
||||
_ => (Vec::new(), 0, false),
|
||||
};
|
||||
let build = if build == Some(JavaBuildStatus::WithErrors) && error_count == 0 && scoped {
|
||||
Some(JavaBuildStatus::Succeeded)
|
||||
} else {
|
||||
build
|
||||
};
|
||||
let cp = cp.unwrap_or_default();
|
||||
Ok(JavaLaunchInfo {
|
||||
build,
|
||||
project_root: cp.project_root,
|
||||
@@ -779,16 +909,20 @@ impl LspManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Compile errors jdtls reports for its workspace (all modules it imported).
|
||||
async fn java_compile_errors(&self, module: &Path) -> (Vec<JavaCompileError>, u32) {
|
||||
let Ok((server, _)) = self.jdtls(module, &CancellationToken::new()).await else { return (Vec::new(), 0) };
|
||||
let collect = || compile_errors(server.diagnostics_snapshot(), MAX_LAUNCH_ERRORS);
|
||||
let found = collect();
|
||||
if found.1 > 0 {
|
||||
return found;
|
||||
}
|
||||
let _ = tokio::time::timeout(ERRORS_WAIT, server.published_notify.notified()).await;
|
||||
collect()
|
||||
/// Wait for late build markers only when no error diagnostics have arrived yet.
|
||||
async fn java_compile_errors(&self, module: &Path, scope: LaunchScope) -> IdeResult<(Vec<JavaCompileError>, u32)> {
|
||||
let (server, _) = self.jdtls(module, &CancellationToken::new()).await?;
|
||||
let diagnostics = server.diagnostics_snapshot();
|
||||
let has_errors = diagnostics.iter().any(|(_, d)| d.severity == Some(lsp_types::DiagnosticSeverity::ERROR));
|
||||
let diagnostics = if has_errors {
|
||||
diagnostics
|
||||
} else {
|
||||
let _ = tokio::time::timeout(ERRORS_WAIT, server.published_notify.notified()).await;
|
||||
server.diagnostics_snapshot()
|
||||
};
|
||||
tokio::task::spawn_blocking(move || launch_compile_errors(&scope, diagnostics))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Resolve a disk/archive class to project source or the server's binary document.
|
||||
@@ -1058,6 +1192,95 @@ mod tests {
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
fn error_at(path: impl Into<PathBuf>) -> (PathBuf, lsp_types::Diagnostic) {
|
||||
(
|
||||
path.into(),
|
||||
lsp_types::Diagnostic {
|
||||
severity: Some(lsp_types::DiagnosticSeverity::ERROR),
|
||||
message: "boom".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_errors_include_dependencies_but_only_the_launched_modules_tests() {
|
||||
for output in ["target/classes", "build/classes/java/main", "build/classes/kotlin/main"] {
|
||||
let cp = JavaClasspath {
|
||||
class_paths: vec!["/p/web/target/classes".into(), format!("/p/service/{output}"), "/m2/lib.jar".into()],
|
||||
module_paths: vec!["/p/api/target/classes".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let paths = [
|
||||
"/p/web/src/main/java/Main.java",
|
||||
"/p/web/src/test/java/MainTest.java",
|
||||
"/p/service/src/main/java/Service.java",
|
||||
"/p/service/src/test/java/ServiceTest.java",
|
||||
"/p/other/src/main/java/Other.java",
|
||||
"/p/web-other/src/main/java/Other.java",
|
||||
"/p/api/src/main/java/Api.java",
|
||||
"/p/web/src/testing/Real.java",
|
||||
];
|
||||
for (test, expected) in [(false, 4), (true, 5)] {
|
||||
let scope = LaunchScope::new(Path::new("/p/web"), test, Some(&cp));
|
||||
assert_eq!(launch_compile_errors(&scope, paths.iter().map(|p| error_at(*p)).collect()).1, expected);
|
||||
assert!(scope.includes(Path::new("/p/service/src/main/java/Service.java")));
|
||||
assert!(!scope.includes(Path::new("/p/service/src/test/java/ServiceTest.java")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_classpaths_retain_all_errors() {
|
||||
for cp in [
|
||||
None,
|
||||
Some(JavaClasspath::default()),
|
||||
Some(JavaClasspath { class_paths: vec!["/unknown/output".into()], ..Default::default() }),
|
||||
Some(JavaClasspath { class_paths: vec!["/m2/lib.jar".into()], ..Default::default() }),
|
||||
Some(JavaClasspath {
|
||||
class_paths: vec!["/p/web/target/classes".into(), "/unknown/output".into()],
|
||||
..Default::default()
|
||||
}),
|
||||
] {
|
||||
let scope = LaunchScope::new(Path::new("/p/web"), false, cp.as_ref());
|
||||
let diagnostics = ["/p/service/src/main/java/Service.java", "/p/other/src/test/java/Test.java"];
|
||||
assert_eq!(launch_compile_errors(&scope, diagnostics.into_iter().map(error_at).collect()).1, 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_scope_normalizes_windows_drive_letters_and_separators() {
|
||||
let cp = JavaClasspath { class_paths: vec![r"C:\p\web\target\classes".into()], ..Default::default() };
|
||||
let scope = LaunchScope::new(Path::new(r"c:\p\web"), false, Some(&cp));
|
||||
assert!(scope.includes(Path::new("C:/p/web/src/main/java/Main.java")));
|
||||
assert!(!scope.includes(Path::new(r"C:\p\web\src\test\java\MainTest.java")));
|
||||
assert!(!scope.includes(Path::new("C:/p/web-other/src/main/java/Main.java")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn launch_scope_resolves_symlinks_and_project_reference_entries() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let module = tmp.path().join("web");
|
||||
let dependency = tmp.path().join("service");
|
||||
std::fs::create_dir_all(module.join("src/main/java")).unwrap();
|
||||
std::fs::create_dir_all(&dependency).unwrap();
|
||||
std::fs::write(dependency.join(".project"), "").unwrap();
|
||||
let alias = tmp.path().join("alias");
|
||||
std::os::unix::fs::symlink(&module, &alias).unwrap();
|
||||
let cp = JavaClasspath {
|
||||
class_paths: vec![
|
||||
alias.join("target/classes").to_string_lossy().into_owned(),
|
||||
crate::uri::path_to_uri_string(&dependency),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let scope = LaunchScope::new(&alias, false, Some(&cp));
|
||||
assert!(scope.includes(&dunce::canonicalize(&module).unwrap().join("src/main/java/Deleted.java")));
|
||||
assert!(scope.includes(&dependency.join("src/main/java/Service.java")));
|
||||
assert!(!scope.includes(&dependency.join("src/test/java/ServiceTest.java")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classpaths() {
|
||||
let v = json!({
|
||||
|
||||
@@ -1821,6 +1821,59 @@ async fn java_launch_serializes_the_classpath_with_the_build_lock() {
|
||||
assert!(commands.contains(&"java.project.getClasspaths".to_owned()));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_launch_scopes_build_status_and_does_not_wait_for_unrelated_errors() {
|
||||
use ide_lsp::jdtls::JavaBuildStatus;
|
||||
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.open("Main.java", "class Main {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let state = h.launcher.state("jdtls");
|
||||
state.respond("java/buildWorkspace", json!(2));
|
||||
let web = h.root.join("web");
|
||||
let classpath = json!({"classpaths": [web.join("target/classes"), h.root.join("service/target/classes")]});
|
||||
for (path, test, expected, classpaths_available) in [
|
||||
("other/src/main/java/Other.java", false, JavaBuildStatus::Succeeded, true),
|
||||
("other/src/main/java/Other.java", false, JavaBuildStatus::Succeeded, true),
|
||||
("web/src/test/java/MainTest.java", false, JavaBuildStatus::Succeeded, true),
|
||||
("web/src/test/java/MainTest.java", true, JavaBuildStatus::WithErrors, true),
|
||||
("service/src/test/java/ServiceTest.java", true, JavaBuildStatus::Succeeded, true),
|
||||
("service/src/main/java/Service.java", false, JavaBuildStatus::WithErrors, true),
|
||||
("web/src/main/java/Main.java", false, JavaBuildStatus::WithErrors, true),
|
||||
("other/src/main/java/Other.java", false, JavaBuildStatus::WithErrors, false),
|
||||
] {
|
||||
state.respond("workspace/executeCommand", classpath.clone());
|
||||
if !classpaths_available {
|
||||
state.errors.lock().insert("workspace/executeCommand".into(), (-32603, "classpath unavailable".into()));
|
||||
}
|
||||
let file = h.root.join(path);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(&file, "broken").unwrap();
|
||||
let diag = json!({"range": {"start":{"line":0,"character":0},"end":{"line":0,"character":1}},
|
||||
"severity": 1, "message": path});
|
||||
state
|
||||
.peer()
|
||||
.notify("textDocument/publishDiagnostics", json!({"uri": uri_of(&file), "diagnostics": [diag]}))
|
||||
.unwrap();
|
||||
eventually(|| diagnostics_for(&h, &file) == 1).await;
|
||||
let info = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
h.mgr.java_launch(&web, test, true, false, &Default::default()),
|
||||
)
|
||||
.await
|
||||
.expect("an already-published error must not incur the diagnostics wait")
|
||||
.unwrap();
|
||||
assert_eq!(info.build, Some(expected), "{path}, test={test}, classpath={classpaths_available}");
|
||||
assert_eq!(info.error_count, u32::from(expected == JavaBuildStatus::WithErrors));
|
||||
state
|
||||
.peer()
|
||||
.notify("textDocument/publishDiagnostics", json!({"uri": uri_of(&file), "diagnostics": []}))
|
||||
.unwrap();
|
||||
eventually(|| diagnostics_for(&h, &file) == 2).await;
|
||||
h.events.lock().clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn open_java_files_are_checked_again_after_a_project_update() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"))));
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms)),
|
||||
"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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"], ¬ification).await.0, 202);
|
||||
assert_eq!(post(&s, "/mcp", &[&bearer(&s), "Mcp-Session-Id: client-one"], ¬ification).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, ¬ification).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);
|
||||
}
|
||||
@@ -1168,6 +1168,7 @@ fn detect_ruby(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
("ruby".into(), Vec::new()),
|
||||
&["bin/rails", "server"],
|
||||
);
|
||||
c = c.debug("rdbg");
|
||||
c.extras.folder = Some("Rails".into());
|
||||
out.push(c);
|
||||
}
|
||||
@@ -1180,12 +1181,15 @@ fn detect_ruby(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
if task == "test" || task == "spec" || task.ends_with(":test") || task.ends_with(":spec") {
|
||||
c = c.test();
|
||||
}
|
||||
c = c.debug("rdbg");
|
||||
c.extras.folder = Some("Rake".into());
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if uses_rspec(root) {
|
||||
out.push(ruby_config("rspec".into(), "rspec".into(), "rspec", ruby_tool(root, "rspec"), &[]).test());
|
||||
out.push(
|
||||
ruby_config("rspec".into(), "rspec".into(), "rspec", ruby_tool(root, "rspec"), &[]).test().debug("rdbg"),
|
||||
);
|
||||
} else if root.join("test").is_dir() && !out.iter().any(|c| c.id == "rake:test") {
|
||||
// Minitest / Test::Unit without a `test` Rake task: load every test file under `test/`.
|
||||
let mut args = Vec::new();
|
||||
@@ -1196,7 +1200,11 @@ fn detect_ruby(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
}
|
||||
args.extend(["-e".to_owned(), MINITEST_RUNNER.to_owned(), "test".to_owned()]);
|
||||
let extra: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
out.push(ruby_config("minitest".into(), "minitest".into(), "ruby", ruby_tool(root, "ruby"), &extra).test());
|
||||
out.push(
|
||||
ruby_config("minitest".into(), "minitest".into(), "ruby", ruby_tool(root, "ruby"), &extra)
|
||||
.test()
|
||||
.debug("rdbg"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -395,6 +395,10 @@ fn canonical_path(path: &Path) -> IdeResult<PathBuf> {
|
||||
let mut resolved = PathBuf::new();
|
||||
for component in std::path::absolute(path)?.components() {
|
||||
match component {
|
||||
std::path::Component::Prefix(_) => {
|
||||
resolved.push(component);
|
||||
continue;
|
||||
}
|
||||
std::path::Component::ParentDir => {
|
||||
resolved.pop();
|
||||
}
|
||||
|
||||
@@ -1063,6 +1063,7 @@ task default: :build
|
||||
assert_eq!(cmdline(find(&c, "rspec")), "bundle exec rspec");
|
||||
assert!(find(&c, "rspec").is_test);
|
||||
assert_eq!(find(&c, "rake:build").extras.folder.as_deref(), Some("Rake"));
|
||||
assert!(c.iter().all(|c| c.debug_adapter.as_deref() == Some("rdbg")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1077,6 +1078,7 @@ fn ruby_minitest_projects_get_a_test_runner() {
|
||||
("bundle", &["exec", "ruby", "-Ilib", "-Itest"].map(String::from)[..])
|
||||
);
|
||||
assert_eq!(m.args.last().map(String::as_str), Some("test"));
|
||||
assert_eq!(m.debug_adapter.as_deref(), Some("rdbg"));
|
||||
// A `test` Rake task or RSpec already runs the tests.
|
||||
let dir = project(&[("Rakefile", "task :test\n"), ("test/a_test.rb", "")]);
|
||||
assert!(!ids(&detect(dir.path())).contains(&"minitest"));
|
||||
|
||||
+58
-34
@@ -408,33 +408,34 @@ pub fn gradle_env(
|
||||
}
|
||||
|
||||
/// Maven options from settings, placed before the goals: `-s <settings.xml>`,
|
||||
/// `-Dmaven.repo.local=<dir>` and `-T <count>` (the thread count).
|
||||
/// `-t <toolchains.xml>`, `-Dmaven.repo.local=<dir>`, `-T <count>` and `-P <profiles>`.
|
||||
pub fn maven_options(root: &Path, s: &Settings) -> Vec<String> {
|
||||
let m = &s.languages.java.maven;
|
||||
let mut out = Vec::new();
|
||||
if let Some(file) = setting_path(root, &m.user_settings) {
|
||||
out.extend(["-s".to_owned(), file.to_string_lossy().into_owned()]);
|
||||
}
|
||||
if let Some(file) = setting_path(root, &m.toolchains) {
|
||||
out.extend(["-t".to_owned(), file.to_string_lossy().into_owned()]);
|
||||
}
|
||||
if let Some(dir) = setting_path(root, &m.local_repository) {
|
||||
out.push(format!("-Dmaven.repo.local={}", dir.to_string_lossy()));
|
||||
}
|
||||
if let Some(threads) = m.thread_count() {
|
||||
out.extend(["-T".to_owned(), threads.to_owned()]);
|
||||
}
|
||||
if let Some(profiles) = non_empty(&m.active_profiles) {
|
||||
out.extend(["-P".to_owned(), profiles.to_owned()]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn maven_settings_prefixes(args: &[String]) -> Vec<usize> {
|
||||
let mut prefixes = vec![0];
|
||||
let mut i = 0;
|
||||
while let Some(arg) = args.get(i) {
|
||||
if (arg == "-s" || arg == "-T") && args.get(i + 1).is_some() {
|
||||
i += 2;
|
||||
} else if arg.starts_with("-Dmaven.repo.local=") {
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
while i < args.len() {
|
||||
let Some(n) = maven_option_len(&args[i..]) else { break };
|
||||
i += n;
|
||||
prefixes.push(i);
|
||||
}
|
||||
prefixes
|
||||
@@ -1110,43 +1111,62 @@ fn is_python_command(cmd: &str) -> bool {
|
||||
|
||||
const PY_RUNNERS: [&str; 3] = ["uv", "poetry", "pdm"];
|
||||
|
||||
/// [`maven_options`] as the groups they are passed in (`-s <file>`, `-Dmaven.repo.local=…`,
|
||||
/// `-T <count>`).
|
||||
fn maven_option_flag(arg: &str) -> Option<&'static str> {
|
||||
for (short, long) in
|
||||
[("-s", "--settings"), ("-t", "--toolchains"), ("-T", "--threads"), ("-P", "--activate-profiles")]
|
||||
{
|
||||
if arg.starts_with(short) || arg == long || arg.starts_with(&format!("{long}=")) {
|
||||
return Some(short);
|
||||
}
|
||||
}
|
||||
arg.starts_with("-Dmaven.repo.local=").then_some("-Dmaven.repo.local")
|
||||
}
|
||||
|
||||
fn maven_option_len(args: &[String]) -> Option<usize> {
|
||||
let arg = args.first()?;
|
||||
maven_option_flag(arg)?;
|
||||
let separate = matches!(
|
||||
arg.as_str(),
|
||||
"-s" | "--settings" | "-t" | "--toolchains" | "-T" | "--threads" | "-P" | "--activate-profiles"
|
||||
);
|
||||
Some(if separate { 2.min(args.len()) } else { 1 })
|
||||
}
|
||||
|
||||
/// [`maven_options`] grouped with each option's value.
|
||||
fn maven_option_groups(options: &[String]) -> Vec<&[String]> {
|
||||
let mut groups = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < options.len() {
|
||||
let n = if options[i] == "-s" || options[i] == "-T" { 2.min(options.len() - i) } else { 1 };
|
||||
let n = maven_option_len(&options[i..]).unwrap_or(1);
|
||||
groups.push(&options[i..i + n]);
|
||||
i += n;
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// Whether `args` set what `group` sets: a user settings file, the local
|
||||
/// repository, or the thread count.
|
||||
/// Whether the configuration sets the same option, including attached and long forms.
|
||||
fn sets_maven_option(args: &[String], group: &[String]) -> bool {
|
||||
match group.first().map(String::as_str) {
|
||||
Some("-s") => args.iter().any(|a| a == "-s" || a == "--settings" || a.starts_with("--settings=")),
|
||||
// `-T 4`, `--threads 4`, `-T4` and `--threads=4` all set the thread count.
|
||||
Some("-T") => args.iter().any(|a| {
|
||||
a == "-T" || a == "--threads" || a.starts_with("--threads=") || (a.starts_with("-T") && a.len() > 2)
|
||||
}),
|
||||
Some(flag) => {
|
||||
let key = flag.split_once('=').map_or(flag, |(k, _)| k);
|
||||
args.iter().any(|a| a.split_once('=').map_or(a.as_str(), |(k, _)| k) == key)
|
||||
|| args
|
||||
.windows(2)
|
||||
.any(|a| a[0] == "-D" && format!("-D{}", a[1].split('=').next().unwrap_or_default()) == key)
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
let Some(flag) = group.first().and_then(|a| maven_option_flag(a)) else { return true };
|
||||
args.iter().any(|a| maven_option_flag(a) == Some(flag))
|
||||
|| (flag == "-Dmaven.repo.local"
|
||||
&& args.windows(2).any(|a| a[0] == "-D" && a[1].starts_with("maven.repo.local=")))
|
||||
}
|
||||
|
||||
/// Settings › Maven's options ahead of the goals, each unless the configuration sets it itself.
|
||||
fn add_maven_options(args: &mut Vec<String>, options: &[String]) {
|
||||
let missing: Vec<String> =
|
||||
maven_option_groups(options).into_iter().filter(|g| !sets_maven_option(args, g)).flatten().cloned().collect();
|
||||
/// Settings options precede the goals. Tool-window checkbox profiles are additive;
|
||||
/// profiles explicitly typed in the configuration replace the Settings profiles.
|
||||
fn add_maven_options(args: &mut Vec<String>, options: &[String], additive_profiles: bool) {
|
||||
let missing: Vec<String> = maven_option_groups(options)
|
||||
.into_iter()
|
||||
.filter(|g| {
|
||||
if additive_profiles && g[0] == "-P" {
|
||||
!args.windows(g.len()).any(|w| w == *g)
|
||||
} else {
|
||||
!sets_maven_option(args, g)
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect();
|
||||
args.splice(0..0, missing);
|
||||
}
|
||||
|
||||
@@ -1315,7 +1335,11 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
{
|
||||
c.command = mvn.to_string_lossy().into_owned();
|
||||
}
|
||||
add_maven_options(&mut c.args, &mvn_options);
|
||||
let additive_profiles = generated_maven_goal(c)
|
||||
&& c.id.split_once(".xml:").is_some_and(|(_, goals)| {
|
||||
!goals.split_whitespace().any(|a| maven_option_flag(a) == Some("-P"))
|
||||
});
|
||||
add_maven_options(&mut c.args, &mvn_options, additive_profiles);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,15 @@ use std::path::{Path, PathBuf};
|
||||
const PREFIX: &str = "jdtls-maven-settings-";
|
||||
|
||||
/// Points jdtls at a user `settings.xml` activating `profiles` (`-P` entries of the
|
||||
/// last Maven sync), written to `dir`. Through `settings`, so a running server
|
||||
/// last Maven sync), combined with Settings profiles and written to `dir`. Through `settings`, so a running server
|
||||
/// re-imports its Maven projects instead of restarting. `!id` entries can't be
|
||||
/// expressed in `settings.xml` and are left to the POMs' own activation.
|
||||
pub fn configure_jdtls(cfg: &mut LanguageServerConfig, root: &Path, s: &Settings, profiles: &[String], dir: &Path) {
|
||||
if cfg.name != "jdtls" {
|
||||
return;
|
||||
}
|
||||
let active: Vec<&str> =
|
||||
profiles.iter().map(|p| p.trim()).filter(|p| !p.is_empty() && !p.starts_with('!')).collect();
|
||||
let profiles = s.languages.java.maven.profiles(profiles);
|
||||
let active: Vec<&str> = profiles.iter().map(String::as_str).filter(|p| !p.starts_with(['!', '-'])).collect();
|
||||
let Some(file) = (!active.is_empty()).then(|| write_settings(root, s, &active, dir)).flatten() else {
|
||||
remove_generated(dir, None);
|
||||
return;
|
||||
@@ -192,6 +192,30 @@ mod tests {
|
||||
assert!(with_active_profiles("<settings></settings>", &["a&b"]).unwrap().contains("a&b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdtls_combines_settings_profiles_and_checkbox_choices() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.java.maven.user_settings = "config files/settings.xml".into();
|
||||
s.languages.java.maven.active_profiles = " a, c, !d, a ".into();
|
||||
std::fs::create_dir_all(tmp.path().join("config files")).unwrap();
|
||||
std::fs::write(tmp.path().join("config files/settings.xml"), "<settings><mirrors/></settings>").unwrap();
|
||||
let mut cfg = jdtls();
|
||||
configure_jdtls(&mut cfg, tmp.path(), &s, &["b".into(), "!c".into(), "d".into()], &tmp.path().join("cache"));
|
||||
let text = std::fs::read_to_string(user_settings(&cfg).unwrap()).unwrap();
|
||||
assert!(text.contains("<mirrors/>"));
|
||||
assert_eq!(text.matches("<activeProfile>a</activeProfile>").count(), 1);
|
||||
assert!(text.contains("<activeProfile>b</activeProfile>"));
|
||||
assert!(!text.contains("<activeProfile>c</activeProfile>"));
|
||||
assert!(!text.contains("<activeProfile>d</activeProfile>"));
|
||||
|
||||
let mut cfg = jdtls();
|
||||
configure_jdtls(&mut cfg, tmp.path(), &s, &[], &tmp.path().join("cache"));
|
||||
let text = std::fs::read_to_string(user_settings(&cfg).unwrap()).unwrap();
|
||||
assert!(text.contains("<activeProfile>a</activeProfile>"));
|
||||
assert!(text.contains("<activeProfile>c</activeProfile>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdtls_imports_with_the_synced_profiles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -148,6 +148,28 @@ pub fn gem_bin_dirs(ruby: &Path, root: &Path) -> Vec<PathBuf> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Prepares a Ruby debug session (rdbg) running in `dir` of the project `root`: the
|
||||
/// project Ruby's `bin` leads `env`'s `PATH` unless the configuration sets one, and the
|
||||
/// `bundle` and `rdbg` installed with that Ruby are returned by name, to start the adapter.
|
||||
pub fn debug_environment(
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
s: &ide_core::settings::Settings,
|
||||
env: &mut std::collections::BTreeMap<String, String>,
|
||||
) -> Vec<(&'static str, PathBuf)> {
|
||||
let ruby = crate::apply::setting_path(root, &s.languages.ruby.interpreter).or_else(|| project_ruby(dir));
|
||||
let Some((ruby, bin)) = ruby.and_then(|r| bin_dir_of(&r).map(|b| (r, b))) else { return Vec::new() };
|
||||
env.entry("PATH".into()).or_insert_with(|| crate::exec::path_with(&[&bin]));
|
||||
let mut gem_bins: Option<Vec<PathBuf>> = None;
|
||||
let mut find = |name: &str| {
|
||||
ide_core::process::find_executable(name, bin.as_os_str()).or_else(|| {
|
||||
let dirs = gem_bins.get_or_insert_with(|| gem_bin_dirs(&ruby, dir));
|
||||
ide_core::process::find_executable(name, &std::env::join_paths(dirs.iter()).ok()?)
|
||||
})
|
||||
};
|
||||
["bundle", "rdbg"].into_iter().filter_map(|name| Some((name, find(name)?))).collect()
|
||||
}
|
||||
|
||||
type NamedRoots = Vec<(String, PathBuf)>;
|
||||
|
||||
/// Standard-library / site directories (`ruby/3.3.0`) and the gems Bundler resolves
|
||||
@@ -216,6 +238,40 @@ mod tests {
|
||||
assert_eq!(gems, vec![("rake-13.4.0".into(), gem), ("rspec-3.13.2".into(), external)]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn debug_sessions_use_the_rdbg_of_the_project_ruby_and_its_path() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let bin = dir.path().join("ruby/bin");
|
||||
let gem_bin = dir.path().join("gems/bin");
|
||||
for path in [&bin, &gem_bin] {
|
||||
std::fs::create_dir_all(path).unwrap();
|
||||
}
|
||||
// Homebrew's Ruby installs gem executables outside its `bin`.
|
||||
let scripts = [
|
||||
(bin.join("ruby"), format!("#!/bin/sh\necho '{}'\n", gem_bin.display())),
|
||||
(bin.join("bundle"), String::new()),
|
||||
(gem_bin.join("rdbg"), String::new()),
|
||||
];
|
||||
for (path, text) in scripts {
|
||||
std::fs::write(&path, text).unwrap();
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let mut s = ide_core::settings::Settings::default();
|
||||
s.languages.ruby.interpreter = bin.join("ruby").to_string_lossy().into_owned();
|
||||
|
||||
let mut env = std::collections::BTreeMap::new();
|
||||
let tools = debug_environment(dir.path(), dir.path(), &s, &mut env);
|
||||
assert_eq!(tools, [("bundle", bin.join("bundle")), ("rdbg", gem_bin.join("rdbg"))]);
|
||||
assert_eq!(std::env::split_paths(&env["PATH"]).next(), Some(bin.clone()));
|
||||
// The configuration's own PATH stays.
|
||||
let mut env = std::collections::BTreeMap::from([("PATH".to_owned(), "/custom".to_owned())]);
|
||||
debug_environment(dir.path(), dir.path(), &s, &mut env);
|
||||
assert_eq!(env["PATH"], "/custom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ruby_version_output() {
|
||||
assert_eq!(
|
||||
|
||||
+126
-3
@@ -343,20 +343,27 @@ fn project_jdk_and_maven_settings() {
|
||||
s.languages.java.jdk = "jdk17/bin/java".into();
|
||||
s.languages.java.maven.home = maven.to_string_lossy().into_owned();
|
||||
s.languages.java.maven.user_settings = "/m2/settings.xml".into();
|
||||
s.languages.java.maven.toolchains = "/m2/toolchains.xml".into();
|
||||
s.languages.java.maven.local_repository = "/m2/repo".into();
|
||||
assert_eq!(project_jdk(tmp.path(), &s).map(|j| j.major), Some(17));
|
||||
assert_eq!(maven_command(tmp.path(), &s), Some(maven.join("bin/mvn")));
|
||||
assert_eq!(maven_options(tmp.path(), &s), ["-s", "/m2/settings.xml", "-Dmaven.repo.local=/m2/repo"]);
|
||||
assert_eq!(
|
||||
maven_options(tmp.path(), &s),
|
||||
["-s", "/m2/settings.xml", "-t", "/m2/toolchains.xml", "-Dmaven.repo.local=/m2/repo"]
|
||||
);
|
||||
|
||||
let mut configs = vec![run("maven", "./mvnw", &["package"]), run("gradle", "./gradlew", &["build"])];
|
||||
apply_to_run_configs(&mut configs, tmp.path(), &s);
|
||||
let home = project_jdk(tmp.path(), &s).unwrap().home.to_string_lossy().into_owned();
|
||||
assert_eq!(configs[0].command, maven.join("bin/mvn").to_string_lossy());
|
||||
assert_eq!(configs[0].args, ["-s", "/m2/settings.xml", "-Dmaven.repo.local=/m2/repo", "package"]);
|
||||
assert_eq!(
|
||||
configs[0].args,
|
||||
["-s", "/m2/settings.xml", "-t", "/m2/toolchains.xml", "-Dmaven.repo.local=/m2/repo", "package"]
|
||||
);
|
||||
assert_eq!(configs[0].env["JAVA_HOME"], home);
|
||||
assert_eq!(configs[1].env["JAVA_HOME"], home);
|
||||
apply_to_run_configs(&mut configs, tmp.path(), &s);
|
||||
assert_eq!(configs[0].args.len(), 4, "applied once");
|
||||
assert_eq!(configs[0].args.len(), 6, "applied once");
|
||||
|
||||
s.languages.java.jdk = "missing".into();
|
||||
assert_eq!(project_jdk(tmp.path(), &s), None);
|
||||
@@ -448,6 +455,122 @@ fn maven_daemon_uses_the_runner_jre_version() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_options_carry_active_profiles_into_the_command_line() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.java.maven.active_profiles = "dev, integration".into();
|
||||
assert_eq!(maven_options(tmp.path(), &s), ["-P", "dev, integration"]);
|
||||
s.languages.java.maven.active_profiles = " ".into();
|
||||
assert!(maven_options(tmp.path(), &s).is_empty());
|
||||
|
||||
// A run configuration's own -P wins; repeated application does not duplicate the settings group.
|
||||
s.languages.java.maven.active_profiles = "dev".into();
|
||||
let mut configs = vec![run("maven", "./mvnw", &["package"])];
|
||||
apply_to_run_configs(&mut configs, tmp.path(), &s);
|
||||
assert_eq!(configs[0].args, ["-P", "dev", "package"]);
|
||||
apply_to_run_configs(&mut configs, tmp.path(), &s);
|
||||
assert_eq!(configs[0].args, ["-P", "dev", "package"]);
|
||||
let mut configured = vec![run("maven", "./mvnw", &["-P", "prod", "package"])];
|
||||
apply_to_run_configs(&mut configured, tmp.path(), &s);
|
||||
assert_eq!(configured[0].args, ["-P", "prod", "package"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_profile_and_toolchain_overrides_accept_every_option_form() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.java.maven.active_profiles = "a".into();
|
||||
s.languages.java.maven.toolchains = "config files/toolchains.xml".into();
|
||||
let toolchains = tmp.path().join("config files/toolchains.xml").to_string_lossy().into_owned();
|
||||
assert_eq!(maven_options(tmp.path(), &s), ["-t", &toolchains, "-P", "a"]);
|
||||
for own in [
|
||||
vec!["-P", "b,!c", "-t", "own file.xml"],
|
||||
vec!["-Pb,!c", "-town file.xml"],
|
||||
vec!["--activate-profiles", "b,!c", "--toolchains", "own file.xml"],
|
||||
vec!["--activate-profiles=b,!c", "--toolchains=own file.xml"],
|
||||
] {
|
||||
let mut args = own.clone();
|
||||
args.push("test");
|
||||
let mut config = run("maven", "mvn", &args);
|
||||
config.detected = false;
|
||||
for _ in 0..3 {
|
||||
apply_to_run_configs(std::slice::from_mut(&mut config), tmp.path(), &s);
|
||||
assert_eq!(config.args, args, "configuration options win: {own:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_goal_profiles_combine_settings_and_checkboxes_but_keep_explicit_overrides() {
|
||||
use crate::apply::prepare_maven_run;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.java.maven.active_profiles = "a".into();
|
||||
s.languages.java.maven.toolchains = "config files/toolchains.xml".into();
|
||||
let mut config = run("maven", "mvn", &["test", "-Pb,!c"]);
|
||||
config.id = "maven:goal:pom.xml:test".into();
|
||||
config.detected = false;
|
||||
let mut expected = maven_options(tmp.path(), &s);
|
||||
expected.extend(["test".into(), "-Pb,!c".into()]);
|
||||
for _ in 0..3 {
|
||||
prepare_maven_run(&mut config, tmp.path(), &s);
|
||||
assert_eq!(config.args, expected);
|
||||
apply_to_run_configs(std::slice::from_mut(&mut config), tmp.path(), &s);
|
||||
assert_eq!(config.args, expected);
|
||||
}
|
||||
s.languages.java.maven.active_profiles = "new".into();
|
||||
prepare_maven_run(&mut config, tmp.path(), &s);
|
||||
assert_eq!(&config.args[2..], ["-P", "new", "test", "-Pb,!c"]);
|
||||
|
||||
for own in
|
||||
[vec!["-P", "prod"], vec!["-Pprod"], vec!["--activate-profiles", "prod"], vec!["--activate-profiles=prod"]]
|
||||
{
|
||||
let mut args = vec!["test"];
|
||||
args.extend(own);
|
||||
let mut config = run("maven", "mvn", &args);
|
||||
config.id = format!("maven:goal:pom.xml:{}", args.join(" "));
|
||||
config.detected = false;
|
||||
for _ in 0..3 {
|
||||
prepare_maven_run(&mut config, tmp.path(), &s);
|
||||
assert_eq!(&config.args[2..], args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_launch_refreshes_attached_and_long_settings_prefixes() {
|
||||
use crate::apply::prepare_maven_run;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("pom.xml"), "<project/>").unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.java.maven.active_profiles = "new".into();
|
||||
s.languages.java.maven.toolchains = "new toolchains.xml".into();
|
||||
for prefix in [
|
||||
vec!["-told file.xml", "-Pa"],
|
||||
vec!["--toolchains", "old file.xml", "--activate-profiles", "a"],
|
||||
vec!["--toolchains=old file.xml", "--activate-profiles=a"],
|
||||
] {
|
||||
for id in
|
||||
["maven:goal:pom.xml:test", "maven:test", "context:maven:test:ExampleTest", "workspace:abc:maven:test"]
|
||||
{
|
||||
let mut args = prefix.clone();
|
||||
args.push("test");
|
||||
let mut config = run("maven", "mvn", &args);
|
||||
config.id = id.into();
|
||||
config.detected = !id.starts_with("maven:goal:");
|
||||
let mut expected = maven_options(tmp.path(), &s);
|
||||
expected.push("test".into());
|
||||
for _ in 0..3 {
|
||||
prepare_maven_run(&mut config, tmp.path(), &s);
|
||||
assert_eq!(config.args, expected, "{id}: {prefix:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_options_carry_the_thread_count_into_the_command_line() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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).
|
||||
///
|
||||
|
||||
@@ -151,6 +151,25 @@ mod tests {
|
||||
assert_eq!(select_asset(&names, rules, WIN), Some(names[4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laravel_lsp_assets() {
|
||||
let names = [
|
||||
"server-v0.0.32-arm64-darwin",
|
||||
"server-v0.0.32-x64-darwin",
|
||||
"server-v0.0.32-arm64-linux",
|
||||
"server-v0.0.32-x64-linux",
|
||||
"server-v0.0.32-x64-win32.exe",
|
||||
];
|
||||
let rules = crate::registry::find("laravel-lsp").unwrap().asset_rules();
|
||||
assert_eq!(select_asset(&names, rules, MAC_ARM), Some(names[0]));
|
||||
assert_eq!(select_asset(&names, rules, MAC_X64), Some(names[1]));
|
||||
assert_eq!(select_asset(&names, rules, LINUX_ARM), Some(names[2]));
|
||||
assert_eq!(select_asset(&names, rules, LINUX_X64), Some(names[3]));
|
||||
assert_eq!(select_asset(&names, rules, WIN), Some(names[4]));
|
||||
// The composer-installed helper is not a release asset.
|
||||
assert_eq!(select_asset(&["laravel-lsp"], rules, LINUX_X64), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kmp_lsp_assets_include_the_native_jar_indexer() {
|
||||
let names = [
|
||||
|
||||
@@ -396,6 +396,35 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
homepage: "https://intelephense.com",
|
||||
needs: &[],
|
||||
},
|
||||
ToolSpec {
|
||||
name: "laravel-lsp",
|
||||
display: "Laravel LSP",
|
||||
kind: LanguageServer,
|
||||
commands: &["laravel-lsp"],
|
||||
// Plain binaries per platform (`server-v*-<arch>-<os>[.exe]`).
|
||||
method: Method::Release {
|
||||
source: ReleaseSource::GitHub { repo: "laravel/lsp" },
|
||||
assets: &[
|
||||
rule(Mac, Arm64, "server-v*-arm64-darwin"),
|
||||
rule(Mac, X64, "server-v*-x64-darwin"),
|
||||
rule(Linux, Arm64, "server-v*-arm64-linux"),
|
||||
rule(Linux, X64, "server-v*-x64-linux"),
|
||||
rule(Windows, X64, "server-v*-x64-win32.exe"),
|
||||
],
|
||||
locate: &[
|
||||
"laravel-lsp",
|
||||
"server-v*-arm64-darwin",
|
||||
"server-v*-x64-darwin",
|
||||
"server-v*-arm64-linux",
|
||||
"server-v*-x64-linux",
|
||||
"server-v*-x64-win32",
|
||||
],
|
||||
},
|
||||
fallback: None,
|
||||
version: ver("laravel-lsp"),
|
||||
homepage: "https://github.com/laravel/lsp",
|
||||
needs: &[],
|
||||
},
|
||||
ToolSpec {
|
||||
name: "gopls",
|
||||
display: "gopls",
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod file_refs;
|
||||
pub mod history;
|
||||
pub mod memory;
|
||||
pub mod process;
|
||||
pub mod project_discovery;
|
||||
pub mod recent;
|
||||
pub mod roots;
|
||||
pub mod settings;
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Bounded discovery of independent projects selected through the workspace folder picker.
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, IpcPath};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MAX_DEPTH: usize = 4;
|
||||
const MAX_ENTRIES: usize = 20_000;
|
||||
const SKIP_DIRS: &[&str] = &["node_modules", "target", "build", "dist", "vendor", ".venv", ".git"];
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, specta::Type)]
|
||||
pub struct DiscoveredProject {
|
||||
pub path: IpcPath,
|
||||
pub root: IpcPath,
|
||||
pub relative_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize, specta::Type)]
|
||||
pub struct ProjectDiscovery {
|
||||
/// Selected projects and folders with no discovered projects retain the direct-add behaviour.
|
||||
pub direct: Vec<IpcPath>,
|
||||
pub projects: Vec<DiscoveredProject>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
fn check_cancel(cancel: &CancellationToken) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() { Err(IdeError::cancelled()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn is_project(dir: &Path, cancel: &CancellationToken) -> IdeResult<bool> {
|
||||
for marker in crate::roots::BUILD_FILES.iter().chain([".git", ".idea", "CMakeLists.txt"].iter()) {
|
||||
check_cancel(cancel)?;
|
||||
if std::fs::symlink_metadata(dir.join(marker))
|
||||
.is_ok_and(|m| m.is_file() || matches!(*marker, ".git" | ".idea") && m.is_dir())
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Does not follow symlinks or descend into a project. The entry budget spans all selected folders.
|
||||
pub fn discover(folders: Vec<PathBuf>, cancel: &CancellationToken) -> IdeResult<ProjectDiscovery> {
|
||||
discover_with_limit(folders, cancel, MAX_ENTRIES)
|
||||
}
|
||||
|
||||
fn discover_with_limit(
|
||||
folders: Vec<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
mut remaining: usize,
|
||||
) -> IdeResult<ProjectDiscovery> {
|
||||
let mut result = ProjectDiscovery::default();
|
||||
let mut seen_roots = HashSet::new();
|
||||
let mut seen_projects = HashSet::new();
|
||||
for root in folders {
|
||||
check_cancel(cancel)?;
|
||||
if !seen_roots.insert(root.clone()) {
|
||||
continue;
|
||||
}
|
||||
let metadata = std::fs::symlink_metadata(&root)?;
|
||||
// A link explicitly selected by the picker may still be added, but never traversed.
|
||||
if metadata.is_symlink() || is_project(&root, cancel)? {
|
||||
result.direct.push(root.into());
|
||||
continue;
|
||||
}
|
||||
if !metadata.is_dir() {
|
||||
return Err(IdeError::invalid("Choose a project folder"));
|
||||
}
|
||||
let mut found = false;
|
||||
let mut queue = VecDeque::from([(root.clone(), 0)]);
|
||||
while let Some((dir, depth)) = queue.pop_front() {
|
||||
check_cancel(cancel)?;
|
||||
if depth > 0 && is_project(&dir, cancel)? {
|
||||
found = true;
|
||||
if seen_projects.insert(dir.clone()) {
|
||||
result.projects.push(DiscoveredProject {
|
||||
relative_path: ide_core::ipc_path::to_ipc(dir.strip_prefix(&root).unwrap()),
|
||||
path: dir.into(),
|
||||
root: root.clone().into(),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if depth == MAX_DEPTH {
|
||||
continue;
|
||||
}
|
||||
if remaining == 0 {
|
||||
result.truncated = true;
|
||||
continue;
|
||||
}
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if depth == 0 => return Err(error.into()),
|
||||
Err(_) => continue,
|
||||
};
|
||||
let mut children = Vec::new();
|
||||
for entry in entries {
|
||||
check_cancel(cancel)?;
|
||||
if remaining == 0 {
|
||||
result.truncated = true;
|
||||
break;
|
||||
}
|
||||
remaining -= 1;
|
||||
let Ok(entry) = entry else { continue };
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
|
||||
children.push(entry.path());
|
||||
}
|
||||
}
|
||||
children.sort();
|
||||
queue.extend(children.into_iter().map(|path| (path, depth + 1)));
|
||||
}
|
||||
if !found {
|
||||
result.direct.push(root.into());
|
||||
}
|
||||
}
|
||||
check_cancel(cancel)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn project(root: &Path, relative: &str, marker: &str) -> PathBuf {
|
||||
let dir = root.join(relative);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
if marker == ".idea" || marker == ".git" {
|
||||
fs::create_dir(dir.join(marker)).unwrap();
|
||||
} else {
|
||||
fs::write(dir.join(marker), "").unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_shared_build_files_and_workspace_markers_without_descending() {
|
||||
for marker in crate::roots::BUILD_FILES.iter().chain([".git", ".idea", "CMakeLists.txt"].iter()) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let parent = project(tmp.path(), "app", marker);
|
||||
project(&parent, "module", "package.json");
|
||||
let result = discover(vec![tmp.path().into()], &CancellationToken::new()).unwrap();
|
||||
assert!(result.direct.is_empty(), "{marker}");
|
||||
assert_eq!(result.projects.len(), 1, "{marker}");
|
||||
assert_eq!(result.projects[0].path, parent);
|
||||
assert_eq!(result.projects[0].relative_path, "app");
|
||||
let direct = discover(vec![parent.clone()], &CancellationToken::new()).unwrap();
|
||||
assert_eq!(direct.direct[0], parent);
|
||||
assert!(direct.projects.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_dependencies_outputs_and_hidden_directories() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
// .git is itself a marker, so test it below a hidden directory.
|
||||
for skip in SKIP_DIRS.iter().filter(|name| **name != ".git").chain([&".hidden"]) {
|
||||
project(root, &format!("{skip}/app"), "Cargo.toml");
|
||||
}
|
||||
project(root, ".hidden/.git/app", "go.mod");
|
||||
let result = discover(vec![root.into()], &CancellationToken::new()).unwrap();
|
||||
assert!(result.projects.is_empty());
|
||||
assert_eq!(result.direct[0], root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_depth_four_but_not_five_and_retains_empty_folders() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let expected = project(tmp.path(), "a/b/c/app", "pom.xml");
|
||||
project(tmp.path(), "deep/b/c/d/app", "package.json");
|
||||
let empty = tmp.path().join("empty");
|
||||
fs::create_dir(&empty).unwrap();
|
||||
let result = discover(vec![tmp.path().into(), empty.clone()], &CancellationToken::new()).unwrap();
|
||||
assert_eq!(result.projects.len(), 1);
|
||||
assert_eq!(result.projects[0].path, expected);
|
||||
assert_eq!(result.direct[0], empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_overlapping_selections_and_shares_the_entry_budget() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
project(root, "a/app", "go.mod");
|
||||
let result = discover(vec![root.into(), root.join("a"), root.into()], &CancellationToken::new()).unwrap();
|
||||
assert_eq!(result.projects.len(), 1);
|
||||
assert!(result.direct.is_empty());
|
||||
let result = discover_with_limit(vec![root.into(), root.join("a")], &CancellationToken::new(), 1).unwrap();
|
||||
assert!(result.truncated);
|
||||
assert!(result.projects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_also_stops_direct_adds() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = project(tmp.path(), "app", "Cargo.toml");
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
let error = discover(vec![root], &cancel).unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::Cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_worktree_files_are_project_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join(".git"), "gitdir: ../main/.git/worktrees/app").unwrap();
|
||||
let result = discover(vec![tmp.path().into()], &CancellationToken::new()).unwrap();
|
||||
assert_eq!(result.direct[0], tmp.path().to_path_buf());
|
||||
assert!(result.projects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_truncation_and_keeps_projects_found_before_the_limit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let first = tmp.path().join("first");
|
||||
let expected = project(&first, "app", "Cargo.toml");
|
||||
let second = tmp.path().join("second");
|
||||
project(&second, "app", "package.json");
|
||||
let result = discover_with_limit(vec![first, second.clone()], &CancellationToken::new(), 1).unwrap();
|
||||
assert!(result.truncated);
|
||||
assert_eq!(result.projects.len(), 1);
|
||||
assert_eq!(result.projects[0].path, expected);
|
||||
assert_eq!(result.direct[0], second);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn never_follows_symlink_loops_or_linked_projects_or_markers() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("container");
|
||||
fs::create_dir(&root).unwrap();
|
||||
let external = project(tmp.path(), "outside", "package.json");
|
||||
symlink(&root, root.join("loop")).unwrap();
|
||||
symlink(&external, root.join("linked")).unwrap();
|
||||
symlink(external.join("package.json"), root.join("package.json")).unwrap();
|
||||
let result = discover(vec![root.clone()], &CancellationToken::new()).unwrap();
|
||||
assert!(result.projects.is_empty());
|
||||
assert_eq!(result.direct[0], root);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const SKIP_DIRS: &[&str] =
|
||||
&["node_modules", "target", "build", "dist", "out", "venv", "__pycache__", "vendor", "bower_components"];
|
||||
|
||||
/// Build files whose changes can change the detected roots.
|
||||
const BUILD_FILES: &[&str] = &[
|
||||
pub(crate) const BUILD_FILES: &[&str] = &[
|
||||
"pom.xml",
|
||||
"build.gradle",
|
||||
"build.gradle.kts",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! project-relative and `/`-separated; `""` is the project root itself.
|
||||
|
||||
mod detect;
|
||||
pub(crate) use detect::BUILD_FILES;
|
||||
mod gradle;
|
||||
mod java;
|
||||
mod maven;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Command-line launcher
|
||||
|
||||
Install the `openidea` command from **OpenIDEA → Install 'openidea' Command** on
|
||||
macOS, or **Tools → Install 'openidea' Command** on Windows and Linux. The same
|
||||
action is available in Find Action. Open a new terminal after installation.
|
||||
|
||||
- **macOS:** move OpenIDEA to Applications first. The command links to the launcher
|
||||
inside the app bundle. It uses `~/.local/bin` when that directory is on PATH;
|
||||
otherwise it uses `/usr/local/bin` and may request an administrator password.
|
||||
- **Windows:** installation creates `openidea.cmd` in
|
||||
`%LOCALAPPDATA%\OpenIDEA\bin` and adds that directory to the user PATH. This
|
||||
works with installed and portable copies, without administrator rights.
|
||||
- **Linux:** installation creates `~/.local/bin/openidea`. If another command
|
||||
takes precedence or this directory is absent from PATH, the notification asks
|
||||
you to put it at the beginning of your shell's PATH. For Bash and Zsh, add
|
||||
`export PATH="$HOME/.local/bin:$PATH"` to your shell profile. For Fish, run
|
||||
`fish_add_path --prepend ~/.local/bin`. An AppImage launcher targets the original
|
||||
AppImage; keep that file in a permanent location.
|
||||
|
||||
```sh
|
||||
openidea .
|
||||
openidea src/main.rs README.md
|
||||
openidea --line 12 --column 5 src/main.rs
|
||||
openidea src/main.rs:12:5
|
||||
openidea diff before.txt after.txt
|
||||
openidea --help
|
||||
openidea --version
|
||||
```
|
||||
|
||||
Folders open as projects. Files open in their project's window, or in LightEdit
|
||||
when no project contains them. Lines and columns start at 1. With no paths, the
|
||||
command focuses OpenIDEA. It starts the app if necessary and returns after handing
|
||||
off the request. Unknown options and missing files exit with code 1.
|
||||
|
||||
Use `--` before a filename beginning with `-`, or before a file named `diff`.
|
||||
The existing GUI executable and OS file associations still work.
|
||||
|
||||
**Uninstall 'openidea' Command** removes only OpenIDEA's launcher, and on Windows
|
||||
its dedicated user PATH entry. A command belonging to another application is
|
||||
never overwritten or removed. Reinstall the command after moving a portable
|
||||
copy or AppImage to a different location.
|
||||
+10
-1
@@ -103,6 +103,10 @@ Three things still layer on top of it:
|
||||
The selection is remembered. When the plugin is disabled or uninstalled the
|
||||
built-in theme returns.
|
||||
|
||||
Stripe tooltips use `--bg-stripe-tooltip`, `--fg-stripe-tooltip`,
|
||||
`--border-stripe-tooltip` and `--shadow-stripe-tooltip`. A plugin that omits
|
||||
these tokens inherits its corresponding base tooltip tokens, including in light themes.
|
||||
|
||||
## Building, packaging, installing
|
||||
|
||||
There is no build step. The package *is* the folder.
|
||||
@@ -324,7 +328,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
242 themeable tokens: 121 in the UI theme file, 121 in the editor colour scheme file.
|
||||
247 themeable tokens: 125 in the UI theme file, 122 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -431,6 +435,7 @@ a token.
|
||||
| `--syn-punct` | `#bcbec4` | `#080808` |
|
||||
| `--syn-variable` | `#bcbec4` | `#080808` |
|
||||
| `--syn-global` | `var(--syn-variable)` | `var(--syn-variable)` |
|
||||
| `--syn-namespace` | `var(--fg-editor)` | `var(--fg-editor)` |
|
||||
| `--syn-param` | `#bcbec4` | `#080808` |
|
||||
| `--syn-property` | `#c77dbb` | `#871094` |
|
||||
| `--syn-constant` | `#c77dbb` | `#871094` |
|
||||
@@ -570,6 +575,10 @@ a token.
|
||||
|
||||
| Token | Islands Dark | Islands Light |
|
||||
|---|---|---|
|
||||
| `--bg-stripe-tooltip` | `var(--bg-tooltip)` | `#27282e` |
|
||||
| `--fg-stripe-tooltip` | `var(--fg-tooltip)` | `#ffffff` |
|
||||
| `--border-stripe-tooltip` | `var(--border-tooltip)` | `#383a42` |
|
||||
| `--shadow-stripe-tooltip` | `var(--shadow-tooltip)` | `0 2px 8px rgba(0, 0, 0, 0.24)` |
|
||||
| `--bg-tooltip` | `#33353b` | `#ffffff` |
|
||||
| `--fg-tooltip` | `#d1d3d9` | `#000000` |
|
||||
| `--border-tooltip` | `#33353b` | `#d1d3d9` |
|
||||
|
||||
@@ -112,9 +112,10 @@ for (const theme of ["dark", "light"] as const) {
|
||||
});
|
||||
expect(geometry.scroll).toBe(geometry.client);
|
||||
expect(geometry.contained).toBe(true);
|
||||
expect(geometry.promptRadius).toBeLessThanOrEqual(4);
|
||||
expect(geometry.replyBackground).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(geometry.replyBorder).toBe("1px");
|
||||
expect(geometry.promptRadius).toBeLessThanOrEqual(6);
|
||||
// Replies read as plain text on the panel.
|
||||
expect(geometry.replyBackground).toBe("rgba(0, 0, 0, 0)");
|
||||
expect(geometry.replyBorder).toBe("0px");
|
||||
expect(geometry.userOnRight).toBe(true);
|
||||
expect(geometry.approvalAligned).toBe(true);
|
||||
expect(geometry.authorBeforeWork).toBe(true);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("a module evaluation failure replaces the splash with copyable details", async ({ page }) => {
|
||||
await page.route("**/src/main.tsx", (route) =>
|
||||
route.fulfill({ contentType: "text/javascript", body: 'throw new Error("Simulated startup failure");' }),
|
||||
);
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "OpenIDEA could not start" })).toBeVisible();
|
||||
await expect(page.locator("#splash")).toHaveCount(0);
|
||||
await expect(page.getByRole("textbox", { name: "Startup error details" })).toHaveValue(/Simulated startup failure/);
|
||||
await expect(page.getByRole("button", { name: "Copy details" })).toBeFocused();
|
||||
await expect(page.getByRole("link", { name: "Report an issue" })).toHaveAttribute("href", /github.com/);
|
||||
});
|
||||
|
||||
test("an unresolved module gets a startup timeout", async ({ page }) => {
|
||||
await page.clock.install();
|
||||
await page.route("**/src/main.tsx", (route) =>
|
||||
route.fulfill({ contentType: "text/javascript", body: "await new Promise(() => {});" }),
|
||||
);
|
||||
await page.goto("/", { waitUntil: "commit" });
|
||||
await page.locator("#splash").waitFor({ state: "attached" });
|
||||
await page.clock.runFor(30_000);
|
||||
await expect(page.getByRole("textbox", { name: "Startup error details" })).toHaveValue(
|
||||
/Startup did not finish within 30 seconds/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a rejected boot shows Chinese diagnostics without application modules", async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale: "zh-CN" });
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await context.route("https://github.com/**", (route) =>
|
||||
route.fulfill({ contentType: "text/html", body: "Issue report destination" }),
|
||||
);
|
||||
await page.route("**/src/main.tsx", (route) =>
|
||||
route.fulfill({ contentType: "text/javascript", body: 'await Promise.reject(new Error("Boot rejected"));' }),
|
||||
);
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "OpenIDEA 无法启动" })).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "启动错误详情" })).toHaveValue(/Boot rejected/);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: async (value: string) => sessionStorage.setItem("copied-diagnostics", value) },
|
||||
});
|
||||
});
|
||||
await page.getByRole("button", { name: "复制详情" }).click();
|
||||
await expect(page.getByRole("status")).toHaveText("已复制");
|
||||
expect(await page.evaluate(() => sessionStorage.getItem("copied-diagnostics"))).toContain("Boot rejected");
|
||||
const popup = page.waitForEvent("popup");
|
||||
await page.getByRole("link", { name: "报告问题" }).click();
|
||||
await expect(await popup).toHaveURL("https://github.com/l0ng-ai/openidea/issues/new?template=bug_report.yml");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a slow module load succeeds before the deadline and cancels the startup timer", async ({ page }) => {
|
||||
await page.clock.install();
|
||||
await page.addInitScript(() => {
|
||||
new MutationObserver((records) => {
|
||||
if (
|
||||
records.some((record) =>
|
||||
[...record.addedNodes].some((node) => node instanceof Element && node.id === "boot-error"),
|
||||
)
|
||||
)
|
||||
sessionStorage.setItem("boot-error-seen", "true");
|
||||
}).observe(document, { childList: true, subtree: true });
|
||||
});
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => (release = resolve));
|
||||
await page.route("**/src/main.tsx", async (route) => {
|
||||
const response = await route.fetch();
|
||||
await gate;
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
const moduleRequest = page.waitForRequest("**/src/main.tsx");
|
||||
await page.goto("/", { waitUntil: "commit" });
|
||||
await moduleRequest;
|
||||
await page.locator("#splash").waitFor({ state: "attached" });
|
||||
await page.clock.runFor(15_000);
|
||||
await expect(page.locator("#boot-error")).toHaveCount(0);
|
||||
release();
|
||||
await expect(page.locator("#root")).not.toBeEmpty();
|
||||
await page.clock.runFor(4000);
|
||||
await expect(page.locator("#splash")).toHaveCount(0);
|
||||
await page.clock.runFor(35_000);
|
||||
await expect(page.locator("#boot-error")).toHaveCount(0);
|
||||
expect(await page.evaluate(() => sessionStorage.getItem("boot-error-seen"))).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, openFromTree, test } from "./fixtures";
|
||||
|
||||
// A JSX comment is `{/* … */}` among JSX children, while its own text parses as
|
||||
// JavaScript, where the tokens are `/* … */`. Toggling must still take it back out.
|
||||
const SRC = `export function App() {
|
||||
return (
|
||||
<div>
|
||||
<b>x</b>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
test("⌘/ twice on a JSX line gives the line back", async ({ workbench: page }) => {
|
||||
await page.evaluate(async (src) => {
|
||||
const backendModule = "/src/editor/core/backend.ts";
|
||||
const mod = await import(/* @vite-ignore */ backendModule);
|
||||
const backend = mod.docBackend() as { files: Map<string, string> };
|
||||
backend.files.set("/dev/acme-api/App.tsx", src);
|
||||
}, SRC);
|
||||
|
||||
const editor = await openFromTree(page, "App.tsx");
|
||||
const content = editor.locator(".cm-content");
|
||||
await editor.locator(".cm-line", { hasText: "<b>x</b>" }).click();
|
||||
|
||||
await page.keyboard.press("Meta+/");
|
||||
await expect(content).toContainText("{/* <b>x</b> */}");
|
||||
|
||||
await page.keyboard.press("Meta+/");
|
||||
await expect(content).not.toContainText("/*");
|
||||
await expect(editor.locator(".cm-line", { hasText: "<b>x</b>" })).toHaveText("<b>x</b>");
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
for (const [id, selector] of [
|
||||
["general", "[class*='radios'] label"],
|
||||
["notifications", "table tbody td:first-child"],
|
||||
["keymap", "[class*='presetLabel']"],
|
||||
["editor.inspections", "[class*='profileRow'] > span:first-child"],
|
||||
["vcs.issueNavigation", "[class*='pageBody'] button:not([aria-label])"],
|
||||
]) {
|
||||
test(`${id} keeps compact labels inside their controls`, async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.evaluate(async (id) => {
|
||||
const localePath = "/src/platform/i18n.ts";
|
||||
await (await import(/* @vite-ignore */ localePath)).setLocale("zh");
|
||||
const stagingPath = "/src/features/settings/model/staging.ts";
|
||||
(await import(/* @vite-ignore */ stagingPath)).selectPage(id);
|
||||
}, id);
|
||||
const labels = dialog.locator(selector);
|
||||
await expect(labels.first()).toBeVisible();
|
||||
for (const width of [1000, 640, 520]) {
|
||||
await page.setViewportSize({ width, height: 760 });
|
||||
const fits = await labels.evaluateAll((labels) =>
|
||||
labels.every((label) => {
|
||||
const box = label.getBoundingClientRect();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(label);
|
||||
const lines = [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
|
||||
return (
|
||||
lines.every((r) => r.left >= box.left - 1 && r.right <= box.right + 1 && r.height <= 24) &&
|
||||
Math.max(...lines.map((r) => r.bottom)) - Math.min(...lines.map((r) => r.top)) <= 24
|
||||
);
|
||||
}),
|
||||
);
|
||||
expect(fits).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const [id, selector] of [
|
||||
["general", "[class*='fontRow'] > label"],
|
||||
["build.console", "[class*='inlineRow'] > label"],
|
||||
]) {
|
||||
test(`${id} keeps a readable width for inline text`, async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
|
||||
await page.evaluate(async (id) => {
|
||||
const localePath = "/src/platform/i18n.ts";
|
||||
await (await import(/* @vite-ignore */ localePath)).setLocale("zh");
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
(await import(/* @vite-ignore */ path)).selectPage(id);
|
||||
}, id);
|
||||
const text = page.locator(selector).first();
|
||||
await expect(text).toBeVisible();
|
||||
await page.setViewportSize({ width: 520, height: 760 });
|
||||
expect((await text.boundingBox())!.width).toBeGreaterThan(100);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
for (const kind of ["password", "sshSecret", "params"] as const) {
|
||||
test(`database ${kind} dialog keeps its fields inside the window`, async ({ workbench: page }) => {
|
||||
await page.evaluate(async (kind) => {
|
||||
const path = "/src/features/database/store.ts";
|
||||
const { openDialog } = await import(/* @vite-ignore */ path);
|
||||
const source = {
|
||||
id: "layout-test",
|
||||
name: "Layout test",
|
||||
driver: "postgres",
|
||||
user: "account_".repeat(30),
|
||||
ssh: { enabled: true, host: "bastion.example.invalid", auth: "password" },
|
||||
};
|
||||
if (kind === "password") openDialog({ kind, source, resolve: () => {} });
|
||||
else if (kind === "sshSecret")
|
||||
openDialog({ kind, source, prompt: "SSH password required for " + "host_".repeat(30), resolve: () => {} });
|
||||
else openDialog({ kind, names: ["parameter_".repeat(25)], resolve: () => {} });
|
||||
}, kind);
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
for (const width of [1000, 640, 500]) {
|
||||
await page.setViewportSize({ width, height: 760 });
|
||||
const fits = await dialog.evaluate((dialog) => {
|
||||
const box = dialog.getBoundingClientRect();
|
||||
return [...dialog.querySelectorAll("input, [class*='form_']")].every((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.left >= box.left && r.right <= box.right && el.scrollWidth <= el.clientWidth + 1;
|
||||
});
|
||||
});
|
||||
expect(fits).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -56,10 +56,7 @@ test("skips binary and unchanged siblings and restores after changing preview fi
|
||||
const toolbar = await openChain(page);
|
||||
await page.evaluate(async () => {
|
||||
const inputPath = "/src/features/vcs/diffInput.ts";
|
||||
const layoutPath = "/src/workbench/toolwindows/store.ts";
|
||||
const { openDiff, withChain } = await import(inputPath);
|
||||
const { useToolWindowLayout } = await import(layoutPath);
|
||||
useToolWindowLayout.setState({ maximized: "left" });
|
||||
const files = [
|
||||
["a.ts", "after\n"],
|
||||
["binary.dat", "\0binary"],
|
||||
@@ -73,7 +70,13 @@ test("skips binary and unchanged siblings and restores after changing preview fi
|
||||
await openDiff(withChain(files, 0), { preview: true });
|
||||
});
|
||||
await expect(toolbar).toContainText("1 difference");
|
||||
await toolbar.getByRole("button", { name: "Maximize Editor", exact: true }).click();
|
||||
await page.evaluate(async () => {
|
||||
const layoutPath = "/src/workbench/toolwindows/store.ts";
|
||||
const { useToolWindowLayout, hideAllToolWindows } = await import(layoutPath);
|
||||
useToolWindowLayout.setState({ maximized: "left" });
|
||||
hideAllToolWindows();
|
||||
});
|
||||
await toolbar.locator("..").locator(".cm-content").last().focus();
|
||||
const selected = page.getByRole("tablist", { name: "Editor tabs" }).locator('[aria-selected="true"]');
|
||||
await page.keyboard.press("F7");
|
||||
await page.keyboard.press("F7");
|
||||
@@ -90,5 +93,6 @@ test("skips binary and unchanged siblings and restores after changing preview fi
|
||||
return (await import(layoutPath)).useToolWindowLayout.getState().maximized;
|
||||
});
|
||||
expect(maximized).toBe("left");
|
||||
await expect(toolbar).toBeHidden();
|
||||
await expect(page.getByRole("tree", { name: "Project", exact: true })).toBeVisible();
|
||||
});
|
||||
+7
-6
@@ -294,18 +294,19 @@ test.describe("10,000-line files", () => {
|
||||
await page.keyboard.press("Meta+ArrowUp");
|
||||
await page.keyboard.press("F7");
|
||||
await expect(counter(page)).toHaveText("1 of 42 differences");
|
||||
const first = await rendered(page);
|
||||
if (opts.mode !== "unified") {
|
||||
expect(first.a.words).toContainEqual(["compute", "modified"]);
|
||||
expect(first.b.words).toContainEqual(["computeFast", "modified"]);
|
||||
await expect.poll(async () => (await rendered(page)).a.words).toContainEqual(["compute", "modified"]);
|
||||
await expect.poll(async () => (await rendered(page)).b.words).toContainEqual(["computeFast", "modified"]);
|
||||
}
|
||||
// The last difference is reachable and still highlighted.
|
||||
await page.keyboard.press("Meta+ArrowDown");
|
||||
await page.keyboard.press("Shift+F7");
|
||||
await expect(counter(page)).toHaveText("42 of 42 differences");
|
||||
const last = await rendered(page);
|
||||
if (opts.mode === "unified") expect(last.b.lines).toContain("inserted");
|
||||
else expect(last.b.words).toContainEqual(["computeFast", "modified"]);
|
||||
if (opts.mode === "unified") {
|
||||
await expect.poll(async () => (await rendered(page)).b.lines).toContain("inserted");
|
||||
} else {
|
||||
await expect.poll(async () => (await rendered(page)).b.words).toContainEqual(["computeFast", "modified"]);
|
||||
}
|
||||
await scrollToEnd(page);
|
||||
if (opts.mode !== "unified") {
|
||||
const [a, b] = await topLines(page);
|
||||
|
||||
Loaded 100 of 453 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user