mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7619931c7e |
No files matched your search
@@ -47,7 +47,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
# No `nightly` tag yet (the first run): compare fails and the build goes on.
|
||||
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null)" || status=ahead
|
||||
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)"
|
||||
if [ "$status" != ahead ] && [ "$FORCE" != true ]; then
|
||||
echo "::notice::$sha is not newer than the last nightly"
|
||||
exit 0
|
||||
|
||||
@@ -151,13 +151,6 @@ jobs:
|
||||
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
|
||||
gh release upload $env:TAG $asset "$asset.sha256" --clobber
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if ($env:TAURI_SIGNING_PRIVATE_KEY) {
|
||||
pnpm exec tauri signer sign --app-version $version $asset
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
gh release upload $env:TAG "$asset.sig" --clobber
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
}
|
||||
|
||||
# The remote development server, per host platform it can run on. The desktop
|
||||
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
|
||||
@@ -240,8 +233,6 @@ jobs:
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
|
||||
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
|
||||
[/_windows_arm64_portable\.zip$/, ["windows-aarch64-portable"]],
|
||||
[/_windows_x64_portable\.zip$/, ["windows-x86_64-portable"]],
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
|
||||
Generated
+51
-51
@@ -4144,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4168,7 +4168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4182,7 +4182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4193,7 +4193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"chardetng",
|
||||
@@ -4211,7 +4211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
@@ -4225,7 +4225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"objc2 0.6.5",
|
||||
@@ -4235,7 +4235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4252,7 +4252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4266,7 +4266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4292,7 +4292,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bb8",
|
||||
@@ -4328,7 +4328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4341,7 +4341,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4362,7 +4362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4374,7 +4374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4387,7 +4387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-heap"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4405,7 +4405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4476,7 +4476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vfs",
|
||||
@@ -4495,7 +4495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4517,7 +4517,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jdwp"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"ide-jvm-inspect",
|
||||
"proptest",
|
||||
@@ -4529,7 +4529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4546,7 +4546,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jvm-attach"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"tempfile",
|
||||
@@ -4555,7 +4555,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jvm-inspect"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"serde",
|
||||
@@ -4566,7 +4566,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4623,7 +4623,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4651,7 +4651,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-mcp"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dunce",
|
||||
@@ -4670,7 +4670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4693,7 +4693,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4716,7 +4716,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jvm-attach",
|
||||
@@ -4730,7 +4730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4750,7 +4750,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4774,7 +4774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-scaffold"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-tools",
|
||||
@@ -4790,7 +4790,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4811,7 +4811,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"encoding_rs",
|
||||
@@ -4832,7 +4832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4845,7 +4845,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4870,7 +4870,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4885,7 +4885,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4898,7 +4898,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4911,7 +4911,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4932,7 +4932,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4953,7 +4953,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4977,7 +4977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"ide-core",
|
||||
@@ -4993,7 +4993,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -6594,7 +6594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -8727,7 +8727,7 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
@@ -8739,7 +8739,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-core"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -8774,7 +8774,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -8786,7 +8786,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dotenvy",
|
||||
@@ -8810,7 +8810,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-mysql"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"byteorder",
|
||||
@@ -8835,7 +8835,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-postgres"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.22.1",
|
||||
@@ -8869,7 +8869,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"flume",
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.44"
|
||||
version = "0.1.43"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -111,7 +111,7 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no-p
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls-platform-verifier = "0.7"
|
||||
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
|
||||
sqlx = { version = "0.9", git = "https://github.com/l0ng-ai/sqlx", rev = "9894985a1babc7e6bc0437da4fd43e3143184073", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
# Pure-Rust thin Oracle driver; pinned to the beta until it stabilizes.
|
||||
oracledb = "=26.0.0-beta.5"
|
||||
|
||||
@@ -107,7 +107,6 @@ fn manager(dir: &Path, agent: Arc<FakeV2Agent>) -> AgentManager {
|
||||
models_file: dir.join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
|
||||
@@ -154,7 +154,6 @@ async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent()
|
||||
models_file: tmp.path().join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
|
||||
@@ -42,38 +42,9 @@ 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())
|
||||
};
|
||||
["mode", "permissions", "approval"]
|
||||
.into_iter()
|
||||
.any(|category| selected(category).zip(bypass_value(category)).is_some_and(|(value, bypass)| bypass(value)))
|
||||
}
|
||||
|
||||
/// For an option of `category` that can approve everything, what tells its approving values.
|
||||
fn bypass_value(category: &str) -> Option<fn(&str) -> bool> {
|
||||
match category {
|
||||
"mode" => Some(is_bypass_mode),
|
||||
"permissions" => Some(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true")),
|
||||
"approval" => Some(|v| v.eq_ignore_ascii_case("bypass")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves options that approve everything without asking to a value that asks: the agent's
|
||||
/// default or ask mode, else its first one that asks.
|
||||
pub(crate) fn ask_for_approvals(config: &mut [AgentConfigOption]) {
|
||||
for option in config {
|
||||
let Some(bypass) = option.category.as_deref().and_then(bypass_value) else { continue };
|
||||
if option.current.as_deref().is_some_and(|c| !bypass(c)) {
|
||||
continue;
|
||||
}
|
||||
let asks = |v: &&AgentConfigValue| !bypass(&v.value);
|
||||
let value = ["default", "ask"]
|
||||
.iter()
|
||||
.find_map(|preferred| option.values.iter().filter(asks).find(|v| v.value == *preferred))
|
||||
.or_else(|| option.values.iter().find(asks));
|
||||
if let Some(value) = value {
|
||||
option.current = Some(value.value.clone());
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -370,36 +341,6 @@ mod tests {
|
||||
assert!(bypass_approvals(&url_mode));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asking_for_approvals_keeps_a_mode_that_asks_and_leaves_one_that_does_not() {
|
||||
let mode = |current: &str, values: &[&str]| {
|
||||
options_of(&json!({"modes": {"currentModeId": current,
|
||||
"availableModes": values.iter().map(|v| json!({"id": v})).collect::<Vec<_>>()}}))
|
||||
};
|
||||
let codex = ["read-only", "ask", "auto", "full-access"];
|
||||
for (current, expected) in [("auto", "auto"), ("read-only", "read-only"), ("full-access", "ask")] {
|
||||
let mut c = mode(current, &codex);
|
||||
ask_for_approvals(&mut c);
|
||||
assert_eq!(c[0].current.as_deref(), Some(expected), "{current}");
|
||||
assert!(!bypass_approvals(&c));
|
||||
}
|
||||
let mut claude = mode("bypassPermissions", &["acceptEdits", "default", "plan", "bypassPermissions"]);
|
||||
ask_for_approvals(&mut claude);
|
||||
assert_eq!(claude[0].current.as_deref(), Some("default"));
|
||||
let mut other = mode("yolo", &["yolo", "code", "architect"]);
|
||||
ask_for_approvals(&mut other);
|
||||
assert_eq!(other[0].current.as_deref(), Some("code"));
|
||||
|
||||
let mut c = options_of(&json!({"configOptions": [
|
||||
{"id": "allow_all", "category": "permissions", "type": "select", "currentValue": "on",
|
||||
"options": [{"value": "on"}, {"value": "off"}]},
|
||||
{"id": "approval", "category": "approval", "type": "select", "currentValue": "bypass",
|
||||
"options": [{"value": "bypass"}, {"value": "ask"}]}
|
||||
]}));
|
||||
ask_for_approvals(&mut c);
|
||||
assert_eq!(ids(&c), [("allow_all", Some("off")), ("approval", Some("ask"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_and_fast_categories_are_inferred_from_ids() {
|
||||
let c = options_of(&json!({
|
||||
|
||||
+14
-61
@@ -43,9 +43,6 @@ pub type AgentSink = Arc<dyn Fn(AgentEvent) + Send + Sync>;
|
||||
/// Returns the active command execution security policy for the project.
|
||||
pub type SecurityHook = Arc<dyn Fn() -> ide_core::settings::AgentSecuritySettings + Send + Sync>;
|
||||
|
||||
/// Returns the user's choice of the approvals new tasks start with.
|
||||
pub type NewTaskApprovalsHook = Arc<dyn Fn() -> ide_core::settings::NewTaskApprovals + Send + Sync>;
|
||||
|
||||
/// Entry changes are batched for this long before they are sent.
|
||||
const FLUSH_DELAY: Duration = Duration::from_millis(40);
|
||||
/// Updates that arrive this late after `session/load` returns are still replayed history.
|
||||
@@ -92,18 +89,6 @@ pub struct AgentPaths {
|
||||
pub custom_agents_dir: Option<PathBuf>,
|
||||
/// The user's home: its `.claude` holds their own skills and commands.
|
||||
pub home_dir: Option<PathBuf>,
|
||||
/// Where the values of the agent files' environment variables are kept; without it they stay in the files.
|
||||
pub env_secrets: Option<Arc<dyn EnvSecrets>>,
|
||||
}
|
||||
|
||||
/// Keeps the values of agent files' environment variables (`agent/<id>/env/<name>`) out of
|
||||
/// the files, which keep only the names; the app keeps them in the OS credential store.
|
||||
pub trait EnvSecrets: Send + Sync {
|
||||
fn get(&self, key: &str) -> Option<String>;
|
||||
fn set(&self, key: &str, value: &str) -> IdeResult<()>;
|
||||
fn delete(&self, key: &str);
|
||||
/// The keys that start with `prefix`.
|
||||
fn keys(&self, prefix: &str) -> Vec<String>;
|
||||
}
|
||||
|
||||
/// Starts the ACP server of a provider in a directory, speaking to `client`.
|
||||
@@ -122,7 +107,6 @@ struct Shared {
|
||||
connector: Connector,
|
||||
sink: RwLock<Option<AgentSink>>,
|
||||
security: RwLock<Option<SecurityHook>>,
|
||||
new_task_approvals: RwLock<Option<NewTaskApprovalsHook>>,
|
||||
known: Mutex<HashMap<String, Known>>,
|
||||
providers: RwLock<Vec<provider::ProviderSpec>>,
|
||||
/// Last probe of each provider.
|
||||
@@ -152,10 +136,6 @@ impl Shared {
|
||||
self.providers.read().iter().find(|p| p.id == id).cloned()
|
||||
}
|
||||
|
||||
fn env_secrets(&self) -> Option<&dyn EnvSecrets> {
|
||||
self.paths.env_secrets.as_deref()
|
||||
}
|
||||
|
||||
fn emit(&self, event: AgentEvent) {
|
||||
let sink = self.sink.read().clone();
|
||||
if let Some(sink) = sink {
|
||||
@@ -175,11 +155,6 @@ impl Shared {
|
||||
// Model-specific options are offered separately, never as provider-wide defaults.
|
||||
config.retain(|o| o.category.as_deref() != Some("thought_level"));
|
||||
config::add_client_options(provider, &mut config);
|
||||
let hook = self.new_task_approvals.read().clone();
|
||||
if hook.is_some_and(|ask| ask() == ide_core::settings::NewTaskApprovals::Ask) {
|
||||
config::ask_for_approvals(&mut config);
|
||||
return config;
|
||||
}
|
||||
if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone()))
|
||||
&& let Some(o) = config.iter_mut().find(|c| c.id == o)
|
||||
{
|
||||
@@ -471,14 +446,13 @@ impl AgentManager {
|
||||
fn with_connector(paths: AgentPaths, runtime: tokio::runtime::Handle, connector: Connector) -> Self {
|
||||
let known =
|
||||
std::fs::read(&paths.models_file).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
let providers = RwLock::new(provider::load(paths.custom_agents_dir.as_deref(), paths.env_secrets.as_deref()));
|
||||
let providers = RwLock::new(provider::load(paths.custom_agents_dir.as_deref()));
|
||||
let shared = Arc::new(Shared {
|
||||
paths,
|
||||
runtime,
|
||||
connector,
|
||||
sink: RwLock::new(None),
|
||||
security: RwLock::new(None),
|
||||
new_task_approvals: RwLock::new(None),
|
||||
known: Mutex::new(known),
|
||||
providers,
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
@@ -527,14 +501,9 @@ impl AgentManager {
|
||||
*self.shared.security.write() = Some(hook);
|
||||
}
|
||||
|
||||
/// Sets the callback that reads which approvals new tasks start with.
|
||||
pub fn set_new_task_approvals_hook(&self, hook: NewTaskApprovalsHook) {
|
||||
*self.shared.new_task_approvals.write() = Some(hook);
|
||||
}
|
||||
|
||||
/// The agents (custom ones re-read), whether each is installed, and what they offered last time.
|
||||
pub fn providers(&self) -> Vec<AgentProvider> {
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets());
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
|
||||
let in_use: std::collections::HashSet<String> =
|
||||
self.tasks.read().values().map(|t| t.state.lock().task.provider.clone()).collect();
|
||||
let list = specs
|
||||
@@ -568,7 +537,7 @@ impl AgentManager {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::set_builtin_command(&dir, self.shared.env_secrets(), id, command.as_deref())
|
||||
provider::set_builtin_command(&dir, id, command.as_deref())
|
||||
}
|
||||
|
||||
/// Sets the environment variables built-in `id` starts with; empty clears them.
|
||||
@@ -576,7 +545,7 @@ impl AgentManager {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::set_builtin_env(&dir, self.shared.env_secrets(), id, env)
|
||||
provider::set_builtin_env(&dir, id, env)
|
||||
}
|
||||
|
||||
/// Saves a new custom ACP agent through its agent file.
|
||||
@@ -584,7 +553,7 @@ impl AgentManager {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::save_custom(&dir, self.shared.env_secrets(), &input)
|
||||
provider::save_custom(&dir, &input)
|
||||
}
|
||||
|
||||
/// Removes a custom agent's file (registry installs go through `registry_remove`).
|
||||
@@ -592,7 +561,7 @@ impl AgentManager {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::remove_custom(&dir, self.shared.env_secrets(), id)
|
||||
provider::remove_custom(&dir, id)
|
||||
}
|
||||
|
||||
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents.
|
||||
@@ -600,12 +569,12 @@ impl AgentManager {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::import_acp(&dir, self.shared.env_secrets(), text)
|
||||
provider::import_acp(&dir, text)
|
||||
}
|
||||
|
||||
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
|
||||
pub fn reload_providers(&self) {
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets());
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
|
||||
*self.shared.providers.write() = specs;
|
||||
self.shared.probes.lock().clear();
|
||||
self.shared.emit(AgentEvent::Providers);
|
||||
@@ -2297,19 +2266,15 @@ impl Task {
|
||||
.iter()
|
||||
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
|
||||
.collect();
|
||||
let command = security::extract_command(params);
|
||||
// What the card names as the command: not for a request that grants more than running it.
|
||||
let shown = command.as_deref().filter(|_| !security::widens_access(params)).map(shown_command);
|
||||
if let Some(hook) = self.shared.security.read().clone()
|
||||
&& let Some(command) = command.as_deref()
|
||||
&& let Some(command) = security::extract_command(params)
|
||||
{
|
||||
match security::evaluate_command(command, &hook()) {
|
||||
match security::evaluate_command(&command, &hook()) {
|
||||
security::PolicyDecision::Blocked(pattern) => {
|
||||
let reject = options.iter().find(|o| o.kind.starts_with("reject")).cloned();
|
||||
let _ = self.push_entry(AgentEntry::Permission {
|
||||
id,
|
||||
title,
|
||||
command: shown.clone(),
|
||||
options,
|
||||
questions: Vec::new(),
|
||||
chosen: Some(format!("Blocked by policy ({pattern})")),
|
||||
@@ -2319,7 +2284,7 @@ impl Task {
|
||||
None => json!({ "outcome": { "outcome": "cancelled" } }),
|
||||
};
|
||||
}
|
||||
security::PolicyDecision::Allowed if !security::widens_access(params) => {
|
||||
security::PolicyDecision::Allowed => {
|
||||
if let Some(allow) = options
|
||||
.iter()
|
||||
.find(|o| o.kind == "allow_once")
|
||||
@@ -2329,7 +2294,6 @@ impl Task {
|
||||
let _ = self.push_entry(AgentEntry::Permission {
|
||||
id,
|
||||
title,
|
||||
command: shown.clone(),
|
||||
options,
|
||||
questions: Vec::new(),
|
||||
chosen: Some(format!("{} (whitelisted)", allow.name)),
|
||||
@@ -2337,18 +2301,12 @@ impl Task {
|
||||
return json!({ "outcome": { "outcome": "selected", "optionId": allow.id } });
|
||||
}
|
||||
}
|
||||
security::PolicyDecision::Allowed | security::PolicyDecision::AskUser => {}
|
||||
security::PolicyDecision::AskUser => {}
|
||||
}
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let entry = self.push_entry(AgentEntry::Permission {
|
||||
id,
|
||||
title,
|
||||
command: shown,
|
||||
options: options.clone(),
|
||||
questions,
|
||||
chosen: None,
|
||||
});
|
||||
let entry =
|
||||
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
|
||||
self.permissions.lock().insert(id, tx);
|
||||
self.set_status(AgentStatus::NeedsInput, None);
|
||||
self.approve_pending();
|
||||
@@ -2510,11 +2468,6 @@ fn permission_title(params: &Value) -> String {
|
||||
title.unwrap_or("The agent asks for permission").to_owned()
|
||||
}
|
||||
|
||||
/// A command as a permission card shows it: the script of a `<shell> -c <script>` line.
|
||||
fn shown_command(command: &str) -> String {
|
||||
shell_script(command).unwrap_or_else(|| command.to_owned())
|
||||
}
|
||||
|
||||
/// The script of a `<shell> -c <quoted script>` command line, when the whole line is exactly that.
|
||||
pub(crate) fn shell_script(command: &str) -> Option<String> {
|
||||
let (shell, rest) = command.split_once(' ')?;
|
||||
|
||||
@@ -384,10 +384,6 @@ pub enum AgentEntry {
|
||||
Permission {
|
||||
id: u32,
|
||||
title: String,
|
||||
/// The shell command the request asks to run, from its structured input; `None` for
|
||||
/// any other request, whatever its title says.
|
||||
#[serde(default)]
|
||||
command: Option<String>,
|
||||
options: Vec<AgentPermissionOption>,
|
||||
/// Set when the request asks `AskUserQuestion`'s questions; answered with a JSON object
|
||||
/// of question to answer instead of an option.
|
||||
|
||||
@@ -869,14 +869,13 @@ impl CodexShim {
|
||||
"item/commandExecution/requestApproval" => {
|
||||
let choices = translate::decisions(params, true);
|
||||
let title = translate::command_approval_title(params, item.as_ref());
|
||||
let mut input = json!({
|
||||
"command": translate::approval_command(params, item.as_ref()),
|
||||
"cwd": params.get("cwd"),
|
||||
});
|
||||
if let Some(network) = params.get("networkApprovalContext").filter(|c| !c.is_null()) {
|
||||
input["networkApprovalContext"] = network.clone();
|
||||
}
|
||||
let call = tool_call_of(&item_id, &title, "execute", reason, Some(input));
|
||||
let call = tool_call_of(
|
||||
&item_id,
|
||||
&title,
|
||||
"execute",
|
||||
reason,
|
||||
Some(json!({ "command": params.get("command"), "cwd": params.get("cwd") })),
|
||||
);
|
||||
Ok(self.ask(&thread, call, &choices, decline))
|
||||
}
|
||||
"item/fileChange/requestApproval" => {
|
||||
|
||||
@@ -757,23 +757,19 @@ pub(super) fn answer_of(choices: &[Choice], outcome: &Value, decline: Value) ->
|
||||
choices.iter().find(|c| selected && c.option["optionId"].as_str() == chosen).map_or(decline, |c| c.answer.clone())
|
||||
}
|
||||
|
||||
/// The command a command approval is about: the request's own, else its item's.
|
||||
pub(super) fn approval_command<'a>(params: &'a Value, item: Option<&'a Value>) -> Option<&'a str> {
|
||||
// Approval payloads can describe a different command from the item's earlier summary.
|
||||
let item = item.filter(|i| i.get("id") == params.get("itemId"));
|
||||
params
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|c| !c.is_empty())
|
||||
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()))
|
||||
}
|
||||
|
||||
/// The ACP title of a command approval.
|
||||
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>) -> String {
|
||||
if let Some(host) = params.pointer("/networkApprovalContext/host").and_then(Value::as_str) {
|
||||
return format!("Allow network access to {host}");
|
||||
}
|
||||
match approval_command(params, item) {
|
||||
// Approval payloads can describe a different command from the item's earlier summary.
|
||||
let item = item.filter(|i| i.get("id") == params.get("itemId"));
|
||||
let command = params
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|c| !c.is_empty())
|
||||
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()));
|
||||
match command {
|
||||
Some(command) => format!("Run {}", crate::shell_script(command).unwrap_or_else(|| command.to_owned())),
|
||||
None => "Run a command".to_owned(),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `<config>/agents/<id>.toml` (written by hand, or by installing one from the ACP
|
||||
//! Registry, see `registry`).
|
||||
|
||||
use crate::{AcpImportOutcome, AgentCommandReadOnlyReason, AgentProvider, AgentSource, CustomAgentInput, EnvSecrets};
|
||||
use crate::{AcpImportOutcome, AgentCommandReadOnlyReason, AgentProvider, AgentSource, CustomAgentInput};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -231,40 +231,23 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
|
||||
|
||||
/// Overrides built-in `id`'s command through its agent file, preserving other
|
||||
/// customizations. `None` restores the built-in command and removes a redundant file.
|
||||
pub(crate) fn set_builtin_command(
|
||||
custom_dir: &Path,
|
||||
secrets: Option<&dyn EnvSecrets>,
|
||||
id: &str,
|
||||
command: Option<&str>,
|
||||
) -> IdeResult<()> {
|
||||
update_builtin(custom_dir, secrets, id, |agent, builtin| {
|
||||
agent.command = command.unwrap_or(&builtin.command).to_owned()
|
||||
})
|
||||
pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&str>) -> IdeResult<()> {
|
||||
update_builtin(custom_dir, id, |agent, builtin| agent.command = command.unwrap_or(&builtin.command).to_owned())
|
||||
}
|
||||
|
||||
/// The environment built-in `id` starts with, through its agent file (empty clears it).
|
||||
pub(crate) fn set_builtin_env(
|
||||
custom_dir: &Path,
|
||||
secrets: Option<&dyn EnvSecrets>,
|
||||
id: &str,
|
||||
env: BTreeMap<String, String>,
|
||||
) -> IdeResult<()> {
|
||||
update_builtin(custom_dir, secrets, id, |agent, _| agent.env = env)
|
||||
pub(crate) fn set_builtin_env(custom_dir: &Path, id: &str, env: BTreeMap<String, String>) -> IdeResult<()> {
|
||||
update_builtin(custom_dir, id, |agent, _| agent.env = env)
|
||||
}
|
||||
|
||||
/// Changes built-in `id`'s agent file; a file left with only the built-in's values is removed.
|
||||
fn update_builtin(
|
||||
custom_dir: &Path,
|
||||
secrets: Option<&dyn EnvSecrets>,
|
||||
id: &str,
|
||||
change: impl FnOnce(&mut CustomAgent, &ProviderSpec),
|
||||
) -> IdeResult<()> {
|
||||
fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAgent, &ProviderSpec)) -> IdeResult<()> {
|
||||
let Some(builtin) = builtin_spec(id) else {
|
||||
return Err(IdeError::invalid(format!("{id} is not a built-in agent")));
|
||||
};
|
||||
let file = custom_dir.join(format!("{id}.toml"));
|
||||
let mut agent = if file.try_exists()? {
|
||||
CustomAgent::read_with(&file, id, secrets).map_err(IdeError::invalid)?
|
||||
CustomAgent::read(&file).map_err(IdeError::invalid)?
|
||||
} else {
|
||||
CustomAgent {
|
||||
name: Some(builtin.name.clone()),
|
||||
@@ -289,20 +272,16 @@ fn update_builtin(
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
|
||||
_ => {}
|
||||
}
|
||||
forget_env(secrets, id, &[]);
|
||||
return Ok(());
|
||||
}
|
||||
agent.write_with(&file, id, secrets)
|
||||
agent.write(&file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves a new custom agent; refuses built-in and ACP Registry ids, taken ids
|
||||
/// and an empty command. The id is also the agent file's name, so it must be
|
||||
/// a safe file stem.
|
||||
pub(crate) fn save_custom(
|
||||
custom_dir: &Path,
|
||||
secrets: Option<&dyn EnvSecrets>,
|
||||
input: &CustomAgentInput,
|
||||
) -> IdeResult<()> {
|
||||
pub(crate) fn save_custom(custom_dir: &Path, input: &CustomAgentInput) -> IdeResult<()> {
|
||||
let id = validated_custom_id(&input.id)?;
|
||||
if builtin_spec(&id).is_some() {
|
||||
return Err(IdeError::invalid(format!("{id} is a built-in agent; change its command instead")));
|
||||
@@ -327,11 +306,12 @@ pub(crate) fn save_custom(
|
||||
env: input.env.clone(),
|
||||
registry: None,
|
||||
}
|
||||
.write_with(&file, &id, secrets)
|
||||
.write(&file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a custom agent's file; refuses built-in, ACP Registry and missing ids.
|
||||
pub(crate) fn remove_custom(custom_dir: &Path, secrets: Option<&dyn EnvSecrets>, id: &str) -> IdeResult<()> {
|
||||
pub(crate) fn remove_custom(custom_dir: &Path, id: &str) -> IdeResult<()> {
|
||||
let id = validated_custom_id(id)?;
|
||||
let id = id.as_str();
|
||||
if builtin_spec(id).is_some() {
|
||||
@@ -345,21 +325,14 @@ pub(crate) fn remove_custom(custom_dir: &Path, secrets: Option<&dyn EnvSecrets>,
|
||||
}
|
||||
match std::fs::remove_file(&file) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
|
||||
_ => {
|
||||
forget_env(secrets, id, &[]);
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom
|
||||
/// agents, one outcome per entry; entries that collide, lack a command or
|
||||
/// have no usable name are skipped with a reason.
|
||||
pub(crate) fn import_acp(
|
||||
custom_dir: &Path,
|
||||
secrets: Option<&dyn EnvSecrets>,
|
||||
text: &str,
|
||||
) -> IdeResult<Vec<AcpImportOutcome>> {
|
||||
pub(crate) fn import_acp(custom_dir: &Path, text: &str) -> IdeResult<Vec<AcpImportOutcome>> {
|
||||
#[derive(Deserialize)]
|
||||
struct AcpJson {
|
||||
#[serde(default)]
|
||||
@@ -397,7 +370,7 @@ pub(crate) fn import_acp(
|
||||
env: server.env,
|
||||
registry: None,
|
||||
}
|
||||
.write_with(&custom_dir.join(format!("{id}.toml")), &id, secrets)?;
|
||||
.write(&custom_dir.join(format!("{id}.toml")))?;
|
||||
outcome.saved = true;
|
||||
}
|
||||
outcomes.push(outcome);
|
||||
@@ -427,7 +400,7 @@ fn slugify_id(name: &str) -> String {
|
||||
}
|
||||
|
||||
/// An agent file, `<config>/agents/<id>.toml`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct CustomAgent {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -474,67 +447,11 @@ impl CustomAgent {
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
/// Reads agent `id`'s file with its environment values filled in from `secrets`; values
|
||||
/// still written in the file (by hand, by an older version) move there first. A registry
|
||||
/// install keeps the environment its registry entry gives it in the file.
|
||||
pub(crate) fn read_with(path: &Path, id: &str, secrets: Option<&dyn EnvSecrets>) -> Result<Self, String> {
|
||||
let mut agent = Self::read(path)?;
|
||||
let Some(secrets) = secrets.filter(|_| agent.registry.is_none()) else {
|
||||
return Ok(agent);
|
||||
};
|
||||
let written = agent.env.values().any(|v| !v.is_empty());
|
||||
for (name, value) in agent.env.iter_mut() {
|
||||
if value.is_empty()
|
||||
&& let Some(saved) = secrets.get(&env_key(id, name))
|
||||
{
|
||||
*value = saved;
|
||||
}
|
||||
}
|
||||
if written && let Err(e) = agent.write_with(path, id, Some(secrets)) {
|
||||
tracing::warn!("agent {id}: could not move environment values out of {}: {}", path.display(), e.message);
|
||||
}
|
||||
Ok(agent)
|
||||
}
|
||||
|
||||
/// Writes agent `id`'s file with the environment's names only, its values saved in `secrets`.
|
||||
pub(crate) fn write_with(&self, path: &Path, id: &str, secrets: Option<&dyn EnvSecrets>) -> IdeResult<()> {
|
||||
let Some(secrets) = secrets.filter(|_| self.registry.is_none()) else {
|
||||
return Ok(self.write(path)?);
|
||||
};
|
||||
let mut keys = Vec::with_capacity(self.env.len());
|
||||
for (name, value) in &self.env {
|
||||
let key = env_key(id, name);
|
||||
if value.is_empty() {
|
||||
secrets.delete(&key);
|
||||
} else if secrets.get(&key).as_deref() != Some(value.as_str()) {
|
||||
secrets.set(&key, value)?;
|
||||
}
|
||||
keys.push(key);
|
||||
}
|
||||
forget_env(Some(secrets), id, &keys);
|
||||
let names = self.env.keys().map(|name| (name.clone(), String::new())).collect();
|
||||
Ok(Self { env: names, ..self.clone() }.write(path)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn env_key(id: &str, name: &str) -> String {
|
||||
format!("agent/{id}/env/{}", name.replace('%', "%25").replace('/', "%2F"))
|
||||
}
|
||||
|
||||
/// Forgets agent `id`'s saved environment values, except `keep`.
|
||||
fn forget_env(secrets: Option<&dyn EnvSecrets>, id: &str, keep: &[String]) {
|
||||
let Some(secrets) = secrets else { return };
|
||||
for key in secrets.keys(&format!("agent/{id}/env/")) {
|
||||
if !keep.contains(&key) {
|
||||
secrets.delete(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in agents, then the `*.toml` agents of `custom_dir`; a custom agent with a
|
||||
/// built-in id replaces it.
|
||||
pub(crate) fn load(custom_dir: Option<&Path>, secrets: Option<&dyn EnvSecrets>) -> Vec<ProviderSpec> {
|
||||
pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
|
||||
let mut specs = builtins();
|
||||
let mut files: Vec<PathBuf> = custom_dir
|
||||
.and_then(|d| std::fs::read_dir(d).ok())
|
||||
@@ -547,7 +464,7 @@ pub(crate) fn load(custom_dir: Option<&Path>, secrets: Option<&dyn EnvSecrets>)
|
||||
files.sort();
|
||||
for path in files {
|
||||
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
|
||||
let agent = match CustomAgent::read_with(&path, &id, secrets) {
|
||||
let agent = match CustomAgent::read(&path) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::warn!("skipping agent {}: {e}", path.display());
|
||||
@@ -617,7 +534,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn qoder_global_and_cn_editions_are_both_built_in() {
|
||||
let info = |id: &str| describe(&load(None, None).into_iter().find(|s| s.id == id).unwrap());
|
||||
let info = |id: &str| describe(&load(None).into_iter().find(|s| s.id == id).unwrap());
|
||||
let (global, cn) = (info("qoder"), info("qodercn"));
|
||||
assert_eq!((global.name.as_str(), global.command.as_str()), ("Qoder", "qodercli --acp"));
|
||||
assert_eq!((cn.name.as_str(), cn.command.as_str()), ("Qoder CN", "qoderclicn --acp"));
|
||||
@@ -627,7 +544,7 @@ mod tests {
|
||||
#[test]
|
||||
fn qodercn_is_available_without_a_file_and_preserves_existing_customizations() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let qodercn = load(None, None).into_iter().find(|s| s.id == "qodercn").unwrap();
|
||||
let qodercn = load(None).into_iter().find(|s| s.id == "qodercn").unwrap();
|
||||
assert_eq!(qodercn.driver, Driver::Acp);
|
||||
assert!(!is_unlisted(&qodercn));
|
||||
let info = describe(&qodercn);
|
||||
@@ -642,13 +559,13 @@ mod tests {
|
||||
"command = \"/custom/qoderclicn\"\nargs = [\"--acp\"]\n[env]\nK = \"v\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let specs = load(Some(dir.path()), None);
|
||||
let specs = load(Some(dir.path()));
|
||||
assert_eq!(specs.iter().filter(|s| s.id == "qodercn").count(), 1);
|
||||
let custom = specs.iter().find(|s| s.id == "qodercn").unwrap();
|
||||
assert_eq!(custom.command, "/custom/qoderclicn");
|
||||
assert_eq!(custom.env, [("K".into(), "v".into())]);
|
||||
set_builtin_command(dir.path(), None, "qodercn", None).unwrap();
|
||||
let reset = load(Some(dir.path()), None).into_iter().find(|s| s.id == "qodercn").unwrap();
|
||||
set_builtin_command(dir.path(), "qodercn", None).unwrap();
|
||||
let reset = load(Some(dir.path())).into_iter().find(|s| s.id == "qodercn").unwrap();
|
||||
assert_eq!(reset.command, "qoderclicn");
|
||||
assert_eq!(reset.args, ["--acp"]);
|
||||
assert_eq!(reset.env, custom.env);
|
||||
@@ -763,7 +680,7 @@ mod tests {
|
||||
#[test]
|
||||
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = || load(Some(dir.path()), None).iter().map(describe).collect::<Vec<_>>();
|
||||
let list = || load(Some(dir.path())).iter().map(describe).collect::<Vec<_>>();
|
||||
let defaults = list();
|
||||
let codex = defaults.iter().find(|p| p.id == "codex").unwrap();
|
||||
assert_eq!(codex.program, "codex");
|
||||
@@ -783,14 +700,14 @@ mod tests {
|
||||
p.id.ends_with("-acp").then_some(AgentCommandReadOnlyReason::PackageRunner)
|
||||
);
|
||||
}
|
||||
set_builtin_command(dir.path(), None, "codex", Some("/path with spaces/codex")).unwrap();
|
||||
set_builtin_command(dir.path(), "codex", Some("/path with spaces/codex")).unwrap();
|
||||
let overridden = list().into_iter().find(|p| p.id == "codex").unwrap();
|
||||
assert_eq!(overridden.program, "/path with spaces/codex");
|
||||
assert_eq!(overridden.args, codex.args);
|
||||
assert_eq!(overridden.default_program, codex.default_program);
|
||||
assert!(overridden.command_overridden && overridden.command_editable);
|
||||
assert_eq!(overridden.source, AgentSource::Builtin);
|
||||
set_builtin_command(dir.path(), None, "codex", Some("codex")).unwrap();
|
||||
set_builtin_command(dir.path(), "codex", Some("codex")).unwrap();
|
||||
assert!(!list().iter().find(|p| p.id == "codex").unwrap().command_overridden);
|
||||
std::fs::write(
|
||||
dir.path().join("gemini.toml"),
|
||||
@@ -815,7 +732,7 @@ mod tests {
|
||||
write("mine.toml", "name = \"Mine\"\ncommand = \"/opt/mine\"\nargs = [\"acp\"]\n[env]\nK = \"v\"\n");
|
||||
write("gemini.toml", "command = \"gemini\"\nargs = [\"--acp\"]\n");
|
||||
write("broken.toml", "nope = 1\n");
|
||||
let specs = load(Some(dir.path()), None);
|
||||
let specs = load(Some(dir.path()));
|
||||
let mine = specs.iter().find(|s| s.id == "mine").unwrap();
|
||||
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
|
||||
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
|
||||
@@ -831,35 +748,34 @@ mod tests {
|
||||
fn set_builtin_env_writes_and_clears_the_environment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let env = BTreeMap::from([("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
|
||||
set_builtin_env(dir.path(), None, "claude", env.clone()).unwrap();
|
||||
let claude = load(Some(dir.path()), None).into_iter().find(|s| s.id == "claude").unwrap();
|
||||
set_builtin_env(dir.path(), "claude", env.clone()).unwrap();
|
||||
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
|
||||
assert_eq!(claude.env, vec![("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
|
||||
assert_eq!(describe(&claude).env, env);
|
||||
// The command override and the environment are kept independently.
|
||||
set_builtin_command(dir.path(), None, "claude", Some("/opt/claude")).unwrap();
|
||||
set_builtin_command(dir.path(), None, "claude", None).unwrap();
|
||||
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
|
||||
set_builtin_command(dir.path(), "claude", None).unwrap();
|
||||
assert!(dir.path().join("claude.toml").is_file());
|
||||
set_builtin_env(dir.path(), None, "claude", BTreeMap::new()).unwrap();
|
||||
set_builtin_env(dir.path(), "claude", BTreeMap::new()).unwrap();
|
||||
assert!(!dir.path().join("claude.toml").exists());
|
||||
set_builtin_env(dir.path(), None, "not-a-builtin", env).unwrap_err();
|
||||
set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_adapters_of_native_agents_are_unlisted_until_customized() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let unlisted =
|
||||
|dir: &Path| load(Some(dir), None).into_iter().filter(is_unlisted).map(|s| s.id).collect::<Vec<_>>();
|
||||
let unlisted = |dir: &Path| load(Some(dir)).into_iter().filter(is_unlisted).map(|s| s.id).collect::<Vec<_>>();
|
||||
assert_eq!(unlisted(dir.path()), ["claude-acp", "codex-acp"]);
|
||||
set_builtin_env(dir.path(), None, "codex-acp", BTreeMap::from([("A".to_owned(), "1".to_owned())])).unwrap();
|
||||
set_builtin_env(dir.path(), "codex-acp", BTreeMap::from([("A".to_owned(), "1".to_owned())])).unwrap();
|
||||
assert_eq!(unlisted(dir.path()), ["claude-acp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_builtin_command_overrides_and_clears_a_builtin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
set_builtin_command(dir.path(), None, "not-a-builtin", Some("/x")).unwrap_err();
|
||||
set_builtin_command(dir.path(), None, "claude", Some("/opt/claude")).unwrap();
|
||||
let claude = load(Some(dir.path()), None).into_iter().find(|s| s.id == "claude").unwrap();
|
||||
set_builtin_command(dir.path(), "not-a-builtin", Some("/x")).unwrap_err();
|
||||
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
|
||||
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
|
||||
assert_eq!((claude.command.as_str(), claude.driver), ("/opt/claude", Driver::Claude));
|
||||
assert!(claude.args.is_empty());
|
||||
// A registry install is neither overwritten nor removed by a command override.
|
||||
@@ -874,14 +790,14 @@ mod tests {
|
||||
}
|
||||
.write(&path)
|
||||
.unwrap();
|
||||
set_builtin_command(dir.path(), None, "codex", Some("/x")).unwrap_err();
|
||||
set_builtin_command(dir.path(), None, "codex", None).unwrap_err();
|
||||
set_builtin_command(dir.path(), "codex", Some("/x")).unwrap_err();
|
||||
set_builtin_command(dir.path(), "codex", None).unwrap_err();
|
||||
assert!(path.exists());
|
||||
// Clearing (twice) returns the built-in to auto-detection.
|
||||
set_builtin_command(dir.path(), None, "claude", None).unwrap();
|
||||
set_builtin_command(dir.path(), None, "claude", None).unwrap();
|
||||
set_builtin_command(dir.path(), "claude", None).unwrap();
|
||||
set_builtin_command(dir.path(), "claude", None).unwrap();
|
||||
assert!(!dir.path().join("claude.toml").exists());
|
||||
assert_eq!(load(Some(dir.path()), None).iter().find(|s| s.id == "claude").unwrap().command, "claude");
|
||||
assert_eq!(load(Some(dir.path())).iter().find(|s| s.id == "claude").unwrap().command, "claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -909,11 +825,11 @@ mod tests {
|
||||
}
|
||||
agent.write(&path).unwrap();
|
||||
|
||||
set_builtin_command(dir.path(), None, "claude", Some("/new/claude")).unwrap();
|
||||
set_builtin_command(dir.path(), "claude", Some("/new/claude")).unwrap();
|
||||
agent.command = "/new/claude".into();
|
||||
assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}");
|
||||
|
||||
set_builtin_command(dir.path(), None, "claude", None).unwrap();
|
||||
set_builtin_command(dir.path(), "claude", None).unwrap();
|
||||
agent.command = "claude".into();
|
||||
assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}");
|
||||
}
|
||||
@@ -929,11 +845,11 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
set_builtin_command(dir.path(), None, "codex", None).unwrap();
|
||||
set_builtin_command(dir.path(), "codex", None).unwrap();
|
||||
|
||||
assert!(!path.exists());
|
||||
assert_eq!(
|
||||
load(Some(dir.path()), None).into_iter().find(|s| s.id == "codex").unwrap(),
|
||||
load(Some(dir.path())).into_iter().find(|s| s.id == "codex").unwrap(),
|
||||
builtin_spec("codex").unwrap()
|
||||
);
|
||||
}
|
||||
@@ -954,7 +870,7 @@ mod tests {
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("[registry]") && !text.contains("driver"), "{text}");
|
||||
assert_eq!(CustomAgent::read(&path).unwrap(), agent);
|
||||
let spec = load(Some(path.parent().unwrap()), None).into_iter().find(|s| s.id == "amp-acp").unwrap();
|
||||
let spec = load(Some(path.parent().unwrap())).into_iter().find(|s| s.id == "amp-acp").unwrap();
|
||||
assert_eq!(spec.source, AgentSource::Registry { version: "0.10.0".into(), update: None });
|
||||
assert!(toml::from_str::<CustomAgent>("command = \"x\"\n[registry]\nid = \"x\"\n").is_err());
|
||||
}
|
||||
@@ -972,117 +888,50 @@ mod tests {
|
||||
#[test]
|
||||
fn custom_agents_save_list_and_remove() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_custom(dir.path(), None, &custom_input("mine")).unwrap();
|
||||
let mine = load(Some(dir.path()), None).into_iter().find(|s| s.id == "mine").unwrap();
|
||||
save_custom(dir.path(), &custom_input("mine")).unwrap();
|
||||
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "mine").unwrap();
|
||||
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
|
||||
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
|
||||
assert_eq!(mine.source, AgentSource::Custom);
|
||||
remove_custom(dir.path(), None, "mine").unwrap();
|
||||
assert!(!load(Some(dir.path()), None).iter().any(|s| s.id == "mine"));
|
||||
remove_custom(dir.path(), None, "mine").unwrap_err();
|
||||
remove_custom(dir.path(), "mine").unwrap();
|
||||
assert!(!load(Some(dir.path())).iter().any(|s| s.id == "mine"));
|
||||
remove_custom(dir.path(), "mine").unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_agents_refuse_builtin_registry_and_bad_ids() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_custom(dir.path(), None, &custom_input("claude")).unwrap_err();
|
||||
save_custom(dir.path(), None, &custom_input("../evil")).unwrap_err();
|
||||
save_custom(dir.path(), None, &custom_input("")).unwrap_err();
|
||||
save_custom(dir.path(), &custom_input("claude")).unwrap_err();
|
||||
save_custom(dir.path(), &custom_input("../evil")).unwrap_err();
|
||||
save_custom(dir.path(), &custom_input("")).unwrap_err();
|
||||
let mut empty_command = custom_input("mine");
|
||||
empty_command.command = " ".into();
|
||||
save_custom(dir.path(), None, &empty_command).unwrap_err();
|
||||
save_custom(dir.path(), None, &custom_input("mine")).unwrap();
|
||||
save_custom(dir.path(), None, &custom_input("mine")).unwrap_err();
|
||||
remove_custom(dir.path(), None, "claude").unwrap_err();
|
||||
save_custom(dir.path(), &empty_command).unwrap_err();
|
||||
save_custom(dir.path(), &custom_input("mine")).unwrap();
|
||||
save_custom(dir.path(), &custom_input("mine")).unwrap_err();
|
||||
remove_custom(dir.path(), "claude").unwrap_err();
|
||||
// An id that isn't a plain file stem never reaches outside the agents directory.
|
||||
let outside = dir.path().join("outside.toml");
|
||||
std::fs::write(&outside, "command = \"/bin/sh\"\n").unwrap();
|
||||
let agents = dir.path().join("agents");
|
||||
std::fs::create_dir(&agents).unwrap();
|
||||
remove_custom(&agents, None, "../outside").unwrap_err();
|
||||
remove_custom(&agents, "../outside").unwrap_err();
|
||||
assert!(outside.exists());
|
||||
std::fs::write(
|
||||
dir.path().join("amp-acp.toml"),
|
||||
"command = \"/registry/amp\"\n[registry]\nid = \"amp-acp\"\nversion = \"1.0\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
save_custom(dir.path(), None, &custom_input("amp-acp")).unwrap_err();
|
||||
remove_custom(dir.path(), None, "amp-acp").unwrap_err();
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemorySecrets(parking_lot::Mutex<BTreeMap<String, String>>);
|
||||
|
||||
impl EnvSecrets for MemorySecrets {
|
||||
fn get(&self, key: &str) -> Option<String> {
|
||||
self.0.lock().get(key).cloned()
|
||||
}
|
||||
fn set(&self, key: &str, value: &str) -> IdeResult<()> {
|
||||
self.0.lock().insert(key.to_owned(), value.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
fn delete(&self, key: &str) {
|
||||
self.0.lock().remove(key);
|
||||
}
|
||||
fn keys(&self, prefix: &str) -> Vec<String> {
|
||||
self.0.lock().keys().filter(|k| k.starts_with(prefix)).cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_values_go_to_the_secrets_and_the_files_keep_the_names() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let secrets = MemorySecrets::default();
|
||||
let s = Some(&secrets as &dyn EnvSecrets);
|
||||
let env = BTreeMap::from([("API_KEY".to_owned(), "v-91f".to_owned()), ("A/B".to_owned(), String::new())]);
|
||||
set_builtin_env(dir.path(), s, "claude", env.clone()).unwrap();
|
||||
save_custom(dir.path(), s, &CustomAgentInput { env: env.clone(), ..custom_input("mine") }).unwrap();
|
||||
for id in ["claude", "mine"] {
|
||||
let text = std::fs::read_to_string(dir.path().join(format!("{id}.toml"))).unwrap();
|
||||
assert!(text.contains("API_KEY") && !text.contains("v-91f"), "{text}");
|
||||
let spec = load(Some(dir.path()), s).into_iter().find(|p| p.id == id).unwrap();
|
||||
assert_eq!(describe(&spec).env, env);
|
||||
}
|
||||
assert_eq!(secrets.keys("agent/"), ["agent/claude/env/API_KEY", "agent/mine/env/API_KEY"]);
|
||||
|
||||
set_builtin_command(dir.path(), s, "claude", Some("/opt/claude")).unwrap();
|
||||
assert_eq!(secrets.get("agent/claude/env/API_KEY").as_deref(), Some("v-91f"));
|
||||
set_builtin_env(dir.path(), s, "claude", BTreeMap::from([("OTHER".to_owned(), "x".to_owned())])).unwrap();
|
||||
assert_eq!(secrets.keys("agent/claude/"), ["agent/claude/env/OTHER"]);
|
||||
set_builtin_command(dir.path(), s, "claude", None).unwrap();
|
||||
set_builtin_env(dir.path(), s, "claude", BTreeMap::new()).unwrap();
|
||||
remove_custom(dir.path(), s, "mine").unwrap();
|
||||
assert_eq!(secrets.keys("agent/"), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_written_in_agent_files_move_to_the_secrets_unless_installed_from_the_registry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let secrets = MemorySecrets::default();
|
||||
let s = Some(&secrets as &dyn EnvSecrets);
|
||||
std::fs::write(dir.path().join("mine.toml"), "command = \"/opt/mine\"\n[env]\nK = \"v-91f\"\nS = \"\"\n")
|
||||
.unwrap();
|
||||
secrets.set("agent/mine/env/S", "saved").unwrap();
|
||||
let registry =
|
||||
"command = \"/registry/amp\"\n[env]\nR = \"1\"\n[registry]\nid = \"amp-acp\"\nversion = \"1.0\"\n";
|
||||
std::fs::write(dir.path().join("amp-acp.toml"), registry).unwrap();
|
||||
let specs = load(Some(dir.path()), s);
|
||||
assert_eq!(
|
||||
specs.iter().find(|p| p.id == "mine").unwrap().env,
|
||||
[("K".to_owned(), "v-91f".to_owned()), ("S".to_owned(), "saved".to_owned())]
|
||||
);
|
||||
assert!(!std::fs::read_to_string(dir.path().join("mine.toml")).unwrap().contains("v-91f"));
|
||||
assert_eq!(secrets.keys("agent/"), ["agent/mine/env/K", "agent/mine/env/S"]);
|
||||
assert_eq!(std::fs::read_to_string(dir.path().join("amp-acp.toml")).unwrap(), registry);
|
||||
save_custom(dir.path(), &custom_input("amp-acp")).unwrap_err();
|
||||
remove_custom(dir.path(), "amp-acp").unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_json_imports_servers_and_skips_collisions() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_custom(dir.path(), None, &custom_input("taken")).unwrap();
|
||||
save_custom(dir.path(), &custom_input("taken")).unwrap();
|
||||
let outcomes = import_acp(
|
||||
dir.path(),
|
||||
None,
|
||||
r#"{"agent_servers": {
|
||||
"My Agent!": {"command": "/opt/agent", "args": ["--acp"], "env": {"K": "v"}},
|
||||
"taken": {"command": "/opt/taken"},
|
||||
@@ -1095,10 +944,10 @@ mod tests {
|
||||
let saved: Vec<&str> = outcomes.iter().filter(|o| o.saved).map(|o| o.id.as_str()).collect();
|
||||
assert_eq!(saved, ["my-agent"]);
|
||||
assert!(outcomes.iter().filter(|o| !o.saved).all(|o| !o.reason.is_empty()));
|
||||
let mine = load(Some(dir.path()), None).into_iter().find(|s| s.id == "my-agent").unwrap();
|
||||
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "my-agent").unwrap();
|
||||
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("My Agent!", "/opt/agent"));
|
||||
assert_eq!(mine.source, AgentSource::Custom);
|
||||
import_acp(dir.path(), None, "not json").unwrap_err();
|
||||
assert!(import_acp(dir.path(), None, "{}").unwrap().is_empty());
|
||||
import_acp(dir.path(), "not json").unwrap_err();
|
||||
assert!(import_acp(dir.path(), "{}").unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -826,7 +826,7 @@ mod tests {
|
||||
assert!(!shown.iter().any(|a| a.id == "cline"));
|
||||
|
||||
write_atomic(&p.cache_file, INDEX.as_bytes()).unwrap();
|
||||
let mut providers: Vec<AgentProvider> = provider::load(Some(&p.agents_dir), None)
|
||||
let mut providers: Vec<AgentProvider> = provider::load(Some(&p.agents_dir))
|
||||
.into_iter()
|
||||
.filter(|s| s.id == "amp-acp")
|
||||
.map(|s| provider::describe(&s))
|
||||
@@ -918,7 +918,7 @@ mod tests {
|
||||
assert_eq!(agent.command, v1.to_string_lossy());
|
||||
assert_eq!((agent.args.as_slice(), agent.env["A"].as_str()), (&["acp".to_owned()][..], "1"));
|
||||
assert_eq!(agent.registry, Some(RegistryRef { id: "demo".into(), version: "1.0.0".into() }));
|
||||
let spec = provider::load(Some(&p.agents_dir), None).into_iter().find(|s| s.id == "demo").unwrap();
|
||||
let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "demo").unwrap();
|
||||
assert_eq!(spec.source, AgentSource::Registry { version: "1.0.0".into(), update: None });
|
||||
assert!(provider::installed(&spec.command));
|
||||
|
||||
@@ -991,7 +991,7 @@ mod tests {
|
||||
assert_eq!((shown[0].installed.as_deref(), shown[0].update), (Some("1.0.104"), true));
|
||||
|
||||
write_atomic(&p.cache_file, index.as_bytes()).unwrap();
|
||||
let spec = provider::load(Some(&p.agents_dir), None).into_iter().find(|s| s.id == "copilot").unwrap();
|
||||
let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
|
||||
assert_eq!(spec.name, "GitHub Copilot");
|
||||
assert_eq!(spec.login_hint, "Run `copilot` in a terminal and sign in.");
|
||||
assert_eq!(spec.source, AgentSource::Registry { version: "1.0.104".into(), update: None });
|
||||
@@ -1004,7 +1004,7 @@ mod tests {
|
||||
);
|
||||
|
||||
remove(&p, "github-copilot-cli").unwrap();
|
||||
let fallback = provider::load(Some(&p.agents_dir), None).into_iter().find(|s| s.id == "copilot").unwrap();
|
||||
let fallback = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
|
||||
assert_eq!(fallback.source, AgentSource::Builtin);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ fn paths(dir: &Path) -> AgentPaths {
|
||||
models_file: dir.join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,23 +13,29 @@ pub enum PolicyDecision {
|
||||
AskUser,
|
||||
}
|
||||
|
||||
/// The shell command an ACP `session/request_permission` asks to run: only a tool call of kind
|
||||
/// `execute`, and only its structured `rawInput` command. A title is free text the agent
|
||||
/// writes, so it never names the command.
|
||||
/// Extracts the shell command string from an ACP `session/request_permission` payload
|
||||
/// when the request is for executing a command.
|
||||
pub fn extract_command(params: &Value) -> Option<String> {
|
||||
let tool = params.get("toolCall").or_else(|| params.pointer("/subject/toolCall"))?;
|
||||
if tool.get("kind").and_then(Value::as_str) != Some("execute") {
|
||||
return None;
|
||||
}
|
||||
let raw_cmd = tool.pointer("/rawInput/command").or_else(|| tool.pointer("/rawInput/cmd"));
|
||||
raw_cmd.and_then(command_from_value)
|
||||
}
|
||||
|
||||
/// Whether allowing the request grants more than running its command, such as network access
|
||||
/// for it: the command policy may block such a request but never approves it.
|
||||
pub fn widens_access(params: &Value) -> bool {
|
||||
let tool = params.get("toolCall").or_else(|| params.pointer("/subject/toolCall"));
|
||||
tool.and_then(|t| t.pointer("/rawInput/networkApprovalContext")).is_some_and(|c| !c.is_null())
|
||||
if let Some(cmd) = raw_cmd.and_then(command_from_value) {
|
||||
return Some(cmd);
|
||||
}
|
||||
let kind = tool.get("kind").and_then(Value::as_str).unwrap_or_default();
|
||||
let name = tool.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
let title =
|
||||
tool.get("title").and_then(Value::as_str).or_else(|| params.get("title").and_then(Value::as_str))?.trim();
|
||||
if let Some(cmd) = title.strip_prefix("Run ").map(str::trim).filter(|s| !s.is_empty()) {
|
||||
return Some(cmd.to_owned());
|
||||
}
|
||||
if (kind == "execute" || matches!(name, "Bash" | "PowerShell" | "Execute" | "Run" | "terminal"))
|
||||
&& !title.is_empty()
|
||||
&& title != "Terminal"
|
||||
&& title != "Run a command"
|
||||
{
|
||||
return Some(title.to_owned());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn command_from_value(val: &Value) -> Option<String> {
|
||||
@@ -292,41 +298,6 @@ fn split_subcommands(command: &str) -> Vec<&str> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether every shell an agent may run `command` in (a POSIX shell, PowerShell, cmd.exe) reads
|
||||
/// it as the one command the policy sees. Outside double quotes no separator or redirection may
|
||||
/// remain after splitting (cmd.exe takes single quotes literally); `\` may only stand in a
|
||||
/// path (it escapes only in POSIX shells); no `^` may escape a quote (cmd.exe); no `%` (cmd.exe
|
||||
/// expands it before parsing); no carriage return; no typographic quotes (PowerShell ends
|
||||
/// strings at them).
|
||||
fn reads_alike_in_every_shell(command: &str) -> bool {
|
||||
let chars: Vec<char> = command.chars().collect();
|
||||
let mut in_double = false;
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
let next = chars.get(i + 1).copied();
|
||||
match c {
|
||||
'"' => in_double = !in_double,
|
||||
'\\' if !next.is_none_or(|n| n.is_ascii_alphanumeric() || matches!(n, '\\' | '.' | '_' | '-')) => {
|
||||
return false;
|
||||
}
|
||||
'^' if matches!(next, Some('"' | '\'' | '\n' | '\r')) => return false,
|
||||
'%' | '\r' | '\u{2018}'..='\u{201f}' => return false,
|
||||
';' | '&' | '|' | '<' | '>' | '\n' if !in_double => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A command as blocked patterns are compared with it: lower case, single spaces, and the
|
||||
/// program without its folder or `.exe` (`/bin/rm` and `RM.EXE` are `rm`).
|
||||
fn normalized_for_blocking(command: &str) -> String {
|
||||
let mut words = command.split_whitespace().map(str::to_lowercase);
|
||||
let Some(program) = words.next() else { return String::new() };
|
||||
let program = program.rsplit(['/', '\\']).next().unwrap_or(&program);
|
||||
let program = program.strip_suffix(".exe").unwrap_or(program);
|
||||
std::iter::once(program.to_owned()).chain(words).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Evaluates `command` against `settings`:
|
||||
/// 1. Blacklist takes precedence: if the full command or any sub-command matches a
|
||||
/// `blocked_commands` entry, returns [`PolicyDecision::Blocked`].
|
||||
@@ -341,18 +312,12 @@ pub fn evaluate_command(command: &str, settings: &AgentSecuritySettings) -> Poli
|
||||
}
|
||||
let subcommands = split_subcommands(cmd);
|
||||
|
||||
let normalized: Vec<String> =
|
||||
std::iter::once(cmd).chain(subcommands.iter().copied()).map(normalized_for_blocking).collect();
|
||||
for pat in &settings.blocked_commands {
|
||||
let pat = pat.trim();
|
||||
if pat.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let normalized_pat = normalized_for_blocking(pat);
|
||||
if matches_command(pat, cmd)
|
||||
|| subcommands.iter().any(|sub| matches_command(pat, sub))
|
||||
|| normalized.iter().any(|c| matches_command(&normalized_pat, c))
|
||||
{
|
||||
if matches_command(pat, cmd) || subcommands.iter().any(|sub| matches_command(pat, sub)) {
|
||||
return PolicyDecision::Blocked(pat.to_owned());
|
||||
}
|
||||
}
|
||||
@@ -363,7 +328,7 @@ pub fn evaluate_command(command: &str, settings: &AgentSecuritySettings) -> Poli
|
||||
|
||||
let all_allowed = subcommands.iter().all(|sub| {
|
||||
let s = sub.trim();
|
||||
if s.is_empty() || has_unsafe_shell_syntax(s) || !reads_alike_in_every_shell(s) {
|
||||
if s.is_empty() || has_unsafe_shell_syntax(s) {
|
||||
return false;
|
||||
}
|
||||
settings.allowed_commands.iter().any(|entry| matches_allow_entry(entry, s))
|
||||
@@ -490,58 +455,6 @@ mod tests {
|
||||
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
|
||||
}
|
||||
|
||||
/// The command may run in PowerShell or cmd.exe as well as a POSIX shell: what any of them
|
||||
/// would split or expand differently from the policy's reading is never auto-approved.
|
||||
#[test]
|
||||
fn what_another_shell_reads_as_two_commands_is_not_auto_approved() {
|
||||
let settings = AgentSecuritySettings {
|
||||
allowed_commands: vec!["git status".into(), "git commit".into()],
|
||||
blocked_commands: vec![],
|
||||
};
|
||||
for cmd in [
|
||||
// `\` escapes only in POSIX shells.
|
||||
"git status \\; calc.exe",
|
||||
"git status \\& calc.exe",
|
||||
"git status \\| calc.exe",
|
||||
"git status \\\\\\; calc.exe",
|
||||
"git status \"\\\" ; calc.exe ; \\\"\"",
|
||||
// cmd.exe: single quotes don't quote, `^` escapes a quote, `%` expands first.
|
||||
"git status '&' calc.exe",
|
||||
"git status ';' calc.exe",
|
||||
"git status '>' out.txt",
|
||||
"git status ^\" & calc.exe ^\"",
|
||||
"git status %COMSPEC%",
|
||||
// PowerShell also closes a string at a typographic quote.
|
||||
"git status \"a \u{201d} ; calc.exe ; \u{201c} b\"",
|
||||
"git status \u{2018}x\u{2019}",
|
||||
// A carriage return ends the command line too.
|
||||
"git status\r calc.exe",
|
||||
] {
|
||||
assert_eq!(evaluate_command(cmd, &settings), PolicyDecision::AskUser, "{cmd:?}");
|
||||
}
|
||||
// Text every shell reads alike stays approvable.
|
||||
for cmd in [
|
||||
"git status -s",
|
||||
"git status && git status",
|
||||
"git commit -m \"fix <bug> & (issue); done\"",
|
||||
"git status src\\main.rs C:\\work\\repo",
|
||||
"git status HEAD^",
|
||||
] {
|
||||
assert_eq!(evaluate_command(cmd, &settings), PolicyDecision::Allowed, "{cmd:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_patterns_ignore_case_spacing_and_the_programs_folder() {
|
||||
let settings = AgentSecuritySettings::default();
|
||||
for cmd in
|
||||
["rm -rf /", "RM -RF /", "/bin/rm -rf /", "Rm -Rf /tmp/x", "C:\\Windows\\System32\\shutdown.exe /s"]
|
||||
{
|
||||
assert!(matches!(evaluate_command(cmd, &settings), PolicyDecision::Blocked(_)), "{cmd:?}");
|
||||
}
|
||||
assert_eq!(evaluate_command("git status", &settings), PolicyDecision::AskUser);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_command_from_v1_v2_and_codex_payloads() {
|
||||
let claude = json!({
|
||||
@@ -571,30 +484,4 @@ mod tests {
|
||||
});
|
||||
assert_eq!(extract_command(&write), None);
|
||||
}
|
||||
|
||||
/// Only a tool call of kind `execute` runs a command, and its command is in `rawInput`:
|
||||
/// a title says what the agent wants it to say.
|
||||
#[test]
|
||||
fn a_request_is_a_command_only_by_its_kind_and_input() {
|
||||
let escalation = json!({ "sessionId": "t", "toolCall": {
|
||||
"toolCallId": "i1", "title": "Run git status", "kind": "other", "status": "pending",
|
||||
"rawInput": { "fileSystem": { "write": ["C:\\"] }, "network": { "enabled": true } } },
|
||||
"options": [ { "optionId": "turn", "name": "Allow for this turn", "kind": "allow_once" },
|
||||
{ "optionId": "decline", "name": "Decline", "kind": "reject_once" } ] });
|
||||
assert_eq!(extract_command(&escalation), None);
|
||||
let titled_only = json!({ "toolCall": { "title": "Run git status", "kind": "execute" } });
|
||||
assert_eq!(extract_command(&titled_only), None);
|
||||
let named_only = json!({ "toolCall": { "title": "git status", "name": "Bash" } });
|
||||
assert_eq!(extract_command(&named_only), None);
|
||||
let mislabelled = json!({ "toolCall": { "title": "Run git status", "kind": "edit",
|
||||
"rawInput": { "command": "git status" } } });
|
||||
assert_eq!(extract_command(&mislabelled), None);
|
||||
let network = json!({ "toolCall": { "title": "Allow network access to example.com", "kind": "execute",
|
||||
"rawInput": { "command": "curl https://example.com", "networkApprovalContext": { "host": "example.com" } } } });
|
||||
assert_eq!(extract_command(&network).as_deref(), Some("curl https://example.com"));
|
||||
assert!(widens_access(&network));
|
||||
let plain = json!({ "toolCall": { "title": "Run git status", "kind": "execute",
|
||||
"rawInput": { "command": "git status" } } });
|
||||
assert!(!widens_access(&plain));
|
||||
}
|
||||
}
|
||||
@@ -264,23 +264,6 @@ impl Handler for FakeAgent {
|
||||
self.say(&session, &format!("late chose {chosen}"));
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "escalate" {
|
||||
// A request for wider access, titled with text the model wrote.
|
||||
let peer = self.latest();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
.block_on(peer.request(
|
||||
"session/request_permission",
|
||||
json!({"sessionId": session, "toolCall": {"toolCallId": "e1", "title": "Run cargo test",
|
||||
"kind": "other", "rawInput": {"network": {"enabled": true}}},
|
||||
"options": [{"optionId": "yes", "name": "Allow for this turn", "kind": "allow_once"},
|
||||
{"optionId": "no", "name": "Decline", "kind": "reject_once"}]}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
|
||||
self.say(&session, &format!("escalation chose {chosen}"));
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "ask" {
|
||||
let peer = self.latest();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
@@ -361,8 +344,7 @@ impl Handler for FakeAgent {
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
.block_on(peer.request(
|
||||
"session/request_permission",
|
||||
json!({"sessionId": session, "toolCall": {"toolCallId": "t1", "title": "Run cargo test",
|
||||
"kind": "execute", "rawInput": {"command": "cargo test"}},
|
||||
json!({"sessionId": session, "toolCall": {"toolCallId": "t1", "title": "Run cargo test"},
|
||||
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
|
||||
{"optionId": "no", "name": "Reject", "kind": "reject_once"}]}),
|
||||
None,
|
||||
@@ -384,8 +366,7 @@ 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, None, "claude", Some(&dir.join("missing-claude").to_string_lossy()))
|
||||
.unwrap();
|
||||
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);
|
||||
@@ -404,7 +385,6 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
|
||||
// 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()),
|
||||
env_secrets: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
@@ -1430,7 +1410,6 @@ async fn real_claude_task_in_a_worktree() {
|
||||
models_file: tmp.path().join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
);
|
||||
@@ -2264,7 +2243,6 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
|
||||
models_file: tmp.path().join("models.json"),
|
||||
custom_agents_dir: Some(agents_dir.clone()),
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
@@ -2575,7 +2553,6 @@ async fn delete_running_agent(no_load: bool) {
|
||||
models_file: root.join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
@@ -3210,43 +3187,3 @@ async fn security_policy_auto_approves_whitelist_and_rejects_blacklist() {
|
||||
"{blocked_entries:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn security_policy_never_auto_approves_a_request_that_only_reads_like_a_command() {
|
||||
use ide_core::settings::AgentSecuritySettings;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent);
|
||||
mgr.set_security_hook(Arc::new(|| AgentSecuritySettings {
|
||||
allowed_commands: vec!["cargo test".into()],
|
||||
blocked_commands: vec![],
|
||||
}));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("escalate"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
let pending = entries
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Permission { id, chosen: None, .. } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("the request was answered without the user: {entries:?}"));
|
||||
mgr.respond(&task.id, pending, Some("no".into())).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(
|
||||
entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == "escalation chose no")),
|
||||
"{entries:?}"
|
||||
);
|
||||
}
|
||||
@@ -438,7 +438,6 @@ mod tests {
|
||||
t.push(AgentEntry::Permission {
|
||||
id: 1,
|
||||
title: "Run?".into(),
|
||||
command: None,
|
||||
options: vec![],
|
||||
questions: vec![],
|
||||
chosen: None,
|
||||
|
||||
@@ -225,15 +225,6 @@ fn same_file(a: &Path, b: &Path) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of `path` (`Some(None)`: no file there), or `None` when it is there but can't be read.
|
||||
fn existing(path: &Path) -> Option<Option<Vec<u8>>> {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => Some(Some(bytes)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(None),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn crlf_to_lf(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
@@ -367,38 +358,6 @@ impl TaskBase {
|
||||
}
|
||||
}
|
||||
|
||||
/// One side of an Accept (the task's worktree or the checkout): where its files really are, and
|
||||
/// git's own folders for it.
|
||||
struct Confined {
|
||||
root: PathBuf,
|
||||
metadata: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Confined {
|
||||
fn new(root: &Path, repo: Option<&Repo>) -> IdeResult<Self> {
|
||||
let metadata = match repo {
|
||||
Some(repo) => repo.metadata_dirs()?.iter().map(|d| ide_core::real_path::resolve_real(d)).collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
Ok(Self { root: dunce::canonicalize(root)?, metadata })
|
||||
}
|
||||
|
||||
/// Whether `rel` may be read or written here: it doesn't name git's metadata under any
|
||||
/// spelling (case, trailing dots or spaces, stream suffixes, short names), stays in the tree
|
||||
/// once links are followed, and isn't a link itself.
|
||||
fn allows(&self, rel: &str) -> bool {
|
||||
use ide_core::real_path::{names_git_dir, reaches_git_dir, resolve_real};
|
||||
let path = self.root.join(rel);
|
||||
if names_git_dir(Path::new(rel)) || path.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) {
|
||||
return false;
|
||||
}
|
||||
let real = resolve_real(&path);
|
||||
real.starts_with(&self.root)
|
||||
&& !reaches_git_dir(&self.root, &real)
|
||||
&& !self.metadata.iter().any(|dir| real.starts_with(dir))
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies the task's `changes` (against `base`) from the worktree `cwd` into the
|
||||
/// local checkout `root`, uncommitted: the user reviews and commits them with the
|
||||
/// VCS tools. Files the checkout changed itself are left alone and reported, so
|
||||
@@ -411,40 +370,28 @@ pub(crate) fn accept(
|
||||
) -> IdeResult<crate::AgentAcceptOutcome> {
|
||||
let repo = repo_at(cwd)?;
|
||||
// Change paths are repository-relative, and the task's project may be a folder inside it.
|
||||
let local = discover(root)?;
|
||||
let local_root = local.as_ref().map_or_else(|| root.to_path_buf(), |r| r.root().to_path_buf());
|
||||
// Paths come from what the agent left in its worktree: neither side may lead into git's own
|
||||
// files or out through a link.
|
||||
let task_side = Confined::new(repo.root(), Some(&repo))?;
|
||||
let checkout_side = Confined::new(&local_root, local.as_ref())?;
|
||||
let copyable = |rel: &str| task_side.allows(rel) && checkout_side.allows(rel);
|
||||
let local_root = discover(root)?.map_or_else(|| root.to_path_buf(), |r| r.root().to_path_buf());
|
||||
let renamed_from: BTreeMap<String, String> =
|
||||
repo.diff_with_worktree(base)?.into_iter().filter_map(|f| Some((f.path, f.old_path?))).collect();
|
||||
// The task's version of one path of the checkout (`Some(None)`: absent there), or `None` when
|
||||
// the checkout changed it itself.
|
||||
let target = |rel: &str| -> IdeResult<Option<Option<Vec<u8>>>> {
|
||||
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
|
||||
let apply = |rel: &str| -> IdeResult<bool> {
|
||||
let local = local_root.join(rel);
|
||||
let in_task = repo.root().join(rel);
|
||||
let current = std::fs::read(&in_task).ok();
|
||||
// A file there that can't be read is the checkout's own: never written over.
|
||||
let Some(local_bytes) = existing(&local_root.join(rel)) else { return Ok(None) };
|
||||
if local_bytes == current {
|
||||
return Ok(Some(current));
|
||||
}
|
||||
let base_bytes = repo.file_bytes_at(&in_task, Some(base))?;
|
||||
let current = std::fs::read(&in_task).ok();
|
||||
let local_bytes = std::fs::read(&local).ok();
|
||||
if local_bytes == current {
|
||||
return Ok(true);
|
||||
}
|
||||
// The checkout may hold the committed LF text with CRLF line ends (`core.autocrlf`).
|
||||
let unchanged = match (&local_bytes, &base_bytes) {
|
||||
(Some(now), Some(at_base)) => now == at_base || crlf_to_lf(now) == *at_base,
|
||||
(now, at_base) => now == at_base,
|
||||
};
|
||||
Ok(unchanged.then_some(current))
|
||||
};
|
||||
// Puts `bytes` (`None`: nothing) at `rel` of the checkout.
|
||||
let write = |rel: &str, bytes: Option<Vec<u8>>| -> IdeResult<()> {
|
||||
let local = local_root.join(rel);
|
||||
if std::fs::read(&local).ok() == bytes {
|
||||
return Ok(());
|
||||
if !unchanged {
|
||||
return Ok(false);
|
||||
}
|
||||
match bytes {
|
||||
match current {
|
||||
Some(bytes) => {
|
||||
if let Some(parent) = local.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
@@ -456,12 +403,6 @@ pub(crate) fn accept(
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
|
||||
let apply = |rel: &str| -> IdeResult<bool> {
|
||||
let Some(bytes) = target(rel)? else { return Ok(false) };
|
||||
write(rel, bytes)?;
|
||||
Ok(true)
|
||||
};
|
||||
let mut accepted = Vec::with_capacity(changes.len());
|
||||
@@ -470,26 +411,13 @@ pub(crate) fn accept(
|
||||
path: path.to_owned(),
|
||||
reason: "the checkout has changes of its own to it; accept it by hand".to_owned(),
|
||||
};
|
||||
let refuse = |path: &str| crate::AgentAcceptSkipped {
|
||||
path: path.to_owned(),
|
||||
reason: "it leads into git's own files or through a link, so it is not copied".to_owned(),
|
||||
};
|
||||
for change in changes {
|
||||
let old = renamed_from.get(&change.rel_path);
|
||||
if !copyable(&change.rel_path) {
|
||||
skipped.push(refuse(&change.rel_path));
|
||||
continue;
|
||||
}
|
||||
if let Some(old) = old.filter(|old| !copyable(old)) {
|
||||
skipped.push(refuse(old));
|
||||
continue;
|
||||
}
|
||||
// A case-only rename (`a.txt` → `A.txt`). Where the disk resolves both
|
||||
// spellings to one file it is renamed in place and `apply` then refreshes
|
||||
// the content. On a case-sensitive disk the two names are different files:
|
||||
// both are checked before either changes — the old one must be the file the
|
||||
// checkout had, the new one absent or already the task's — then the task's
|
||||
// version lands at the new name before the old one goes.
|
||||
// the old one's unchanged-ness (it is the file the checkout had) decides —
|
||||
// then the task's version lands at the new name and the old one goes.
|
||||
let old = renamed_from.get(&change.rel_path);
|
||||
let case_only =
|
||||
old.is_some_and(|o| o != &change.rel_path && o.to_lowercase() == change.rel_path.to_lowercase());
|
||||
if case_only {
|
||||
@@ -498,18 +426,20 @@ pub(crate) fn accept(
|
||||
if same_file(&old_path, &new_path) {
|
||||
std::fs::rename(&old_path, &new_path)?;
|
||||
} else {
|
||||
let Some(old_target) = target(old)? else {
|
||||
if !apply(old)? {
|
||||
skipped.push(skip(old));
|
||||
continue;
|
||||
};
|
||||
let task_bytes = std::fs::read(repo.root().join(&change.rel_path)).ok();
|
||||
let at_new = existing(&new_path);
|
||||
if at_new.as_ref().is_none_or(|at_new| at_new.is_some() && *at_new != task_bytes) {
|
||||
skipped.push(skip(&change.rel_path));
|
||||
continue;
|
||||
}
|
||||
write(&change.rel_path, task_bytes)?;
|
||||
write(old, old_target)?;
|
||||
// `apply(old)` removed the old file (the task's tree has it only at
|
||||
// the new name); now write that version under the new name. `apply`
|
||||
// can't do it — it reads the new name's own base/absence — so the
|
||||
// write is open-coded from the same task bytes `apply` would use.
|
||||
if let Ok(bytes) = std::fs::read(repo.root().join(&change.rel_path)) {
|
||||
if let Some(parent) = new_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&new_path, bytes)?;
|
||||
}
|
||||
accepted.push(change.rel_path.clone());
|
||||
continue;
|
||||
}
|
||||
@@ -980,138 +910,6 @@ mod tests {
|
||||
assert_eq!(names, ["A.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_case_only_rename_keeps_a_file_the_checkout_has_at_the_new_name() {
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
std::fs::write(repo.join("A.txt"), "mine\n").unwrap();
|
||||
if std::fs::read_to_string(repo.join("a.txt")).unwrap() == "mine\n" {
|
||||
eprintln!("this disk doesn't tell a.txt from A.txt; nothing to check");
|
||||
return;
|
||||
}
|
||||
git(&created.path, &["mv", "a.txt", "A.txt"]);
|
||||
let changes = listed(&created.path, &created.base_commit);
|
||||
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
|
||||
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["A.txt"]);
|
||||
assert_eq!(std::fs::read_to_string(repo.join("A.txt")).unwrap(), "mine\n");
|
||||
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\ntwo\n");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn accept_leaves_a_checkout_file_it_cannot_read() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
std::fs::write(created.path.join("new.txt"), "task\n").unwrap();
|
||||
std::fs::write(repo.join("new.txt"), "mine\n").unwrap();
|
||||
std::fs::set_permissions(repo.join("new.txt"), std::fs::Permissions::from_mode(0o200)).unwrap();
|
||||
if std::fs::read(repo.join("new.txt")).is_ok() {
|
||||
eprintln!("running as a user that reads any file; nothing to check");
|
||||
return;
|
||||
}
|
||||
let changes = listed(&created.path, &created.base_commit);
|
||||
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
|
||||
std::fs::set_permissions(repo.join("new.txt"), std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["new.txt"]);
|
||||
assert_eq!(std::fs::read_to_string(repo.join("new.txt")).unwrap(), "mine\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn accept_does_not_write_into_the_checkouts_git_folder_through_its_short_name() {
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
if !repo.join("GIT~1").join("HEAD").is_file() {
|
||||
eprintln!("8.3 short names are disabled on this volume; nothing to check");
|
||||
return;
|
||||
}
|
||||
// Written in the worktree under the name the checkout's `.git` folder also has.
|
||||
let wt = &created.path;
|
||||
std::fs::rename(wt.join(".git"), wt.join("gitfile")).unwrap();
|
||||
std::fs::create_dir_all(wt.join("GIT~1").join("hooks")).unwrap();
|
||||
std::fs::write(wt.join("GIT~1").join("hooks").join("pre-commit"), "#!/bin/sh\nexit 0\n").unwrap();
|
||||
std::fs::rename(wt.join("gitfile"), wt.join(".git")).unwrap();
|
||||
std::fs::write(wt.join("a.txt"), "one\nTWO\n").unwrap();
|
||||
|
||||
let changes = listed(wt, &created.base_commit);
|
||||
assert!(changes.iter().any(|c| c.rel_path == "GIT~1/hooks/pre-commit"), "control: git lists it");
|
||||
let outcome = accept(&repo, wt, &created.base_commit, &changes).unwrap();
|
||||
assert!(!repo.join(".git").join("hooks").join("pre-commit").exists(), "{outcome:?}");
|
||||
assert_eq!(outcome.accepted, ["a.txt"]);
|
||||
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["GIT~1/hooks/pre-commit"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_refuses_paths_that_lead_into_git_metadata_under_any_spelling() {
|
||||
let (tmp, repo, _created) = accept_repo();
|
||||
let checkout = Confined::new(&repo, Repo::discover(&repo).unwrap().as_ref()).unwrap();
|
||||
for rel in [
|
||||
".git/hooks/pre-commit",
|
||||
".GIT/hooks/pre-commit",
|
||||
".git./hooks/pre-commit",
|
||||
".git /hooks/pre-commit",
|
||||
".git::$INDEX_ALLOCATION/hooks/pre-commit",
|
||||
"sub/.git/config",
|
||||
"../outside.txt",
|
||||
] {
|
||||
assert!(!checkout.allows(rel), "{rel}");
|
||||
}
|
||||
assert!(checkout.allows("src/new.txt"));
|
||||
assert!(checkout.allows(".github/workflows/ci.yml"));
|
||||
|
||||
// git's folder under another name inside the tree (`--separate-git-dir`).
|
||||
let separate = tmp.path().join("separate");
|
||||
std::fs::create_dir(&separate).unwrap();
|
||||
git(&separate, &["init", "-q", "--separate-git-dir", "meta", "-b", "main"]);
|
||||
let side = Confined::new(&separate, Repo::discover(&separate).unwrap().as_ref()).unwrap();
|
||||
assert!(!side.allows("meta/hooks/pre-commit"));
|
||||
assert!(side.allows("metadata.txt"));
|
||||
}
|
||||
|
||||
/// A link the task left in its worktree, or a linked folder in the checkout: neither is followed.
|
||||
#[test]
|
||||
fn accept_does_not_follow_links_on_either_side() {
|
||||
let (tmp, repo, created) = accept_repo();
|
||||
let outside = tmp.path().join("outside");
|
||||
std::fs::create_dir(&outside).unwrap();
|
||||
std::fs::write(outside.join("secret.txt"), "secret\n").unwrap();
|
||||
// A folder link in the checkout (a junction on Windows, which needs no privilege).
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&outside, repo.join("linked")).unwrap();
|
||||
#[cfg(windows)]
|
||||
assert!(
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/c", "mklink", "/J"])
|
||||
.arg(repo.join("linked"))
|
||||
.arg(&outside)
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
std::fs::create_dir(created.path.join("linked")).unwrap();
|
||||
std::fs::write(created.path.join("linked/new.txt"), "x\n").unwrap();
|
||||
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
|
||||
// A file link in the worktree, where the platform lets this user create one.
|
||||
#[cfg(unix)]
|
||||
let file_link = std::os::unix::fs::symlink(outside.join("secret.txt"), created.path.join("copied.txt"));
|
||||
#[cfg(windows)]
|
||||
let file_link = std::os::windows::fs::symlink_file(outside.join("secret.txt"), created.path.join("copied.txt"));
|
||||
if let Err(e) = &file_link {
|
||||
eprintln!("no file link ({e}); checking the folder link only");
|
||||
}
|
||||
|
||||
let changes: Vec<_> = listed(&created.path, &created.base_commit);
|
||||
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
|
||||
assert!(!outside.join("new.txt").exists(), "{outcome:?}");
|
||||
assert!(!repo.join("copied.txt").exists(), "{outcome:?}");
|
||||
assert!(outcome.accepted.contains(&"a.txt".to_owned()));
|
||||
let refused: Vec<_> = outcome.skipped.iter().map(|s| s.path.as_str()).collect();
|
||||
if file_link.is_ok() {
|
||||
assert_eq!(refused, ["copied.txt", "linked/new.txt"]);
|
||||
} else {
|
||||
assert_eq!(refused, ["linked/new.txt"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_copies_new_modified_and_deleted_files_into_the_checkout() {
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
|
||||
@@ -12,8 +12,7 @@ mod up_to_date;
|
||||
pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint};
|
||||
pub use launch_resources::process_launch_resources;
|
||||
pub use single::{
|
||||
SingleFileTarget, compile_file_plan, find_javac, javac_step, module_classpath, single_file_classpath,
|
||||
single_file_target,
|
||||
SingleFileTarget, compile_file_plan, find_javac, javac_step, single_file_classpath, single_file_target,
|
||||
};
|
||||
pub use up_to_date::{menu_up_to_date, record_menu_build};
|
||||
|
||||
|
||||
@@ -4,21 +4,12 @@
|
||||
//! walks the whole reactor; compiling one file only needs that file's module
|
||||
//! outputs plus its dependency classpath, which is much faster to assemble.
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::process::Command as ProcCommand;
|
||||
|
||||
/// Output file of the `dependency:build-classpath` probe, relative to each module.
|
||||
const CP_FILE: &str = "target/ide-compile-cp.txt";
|
||||
|
||||
/// The resolved dependency classpath for one module and scope, tied to the same
|
||||
/// inputs as its launch build so POM, JDK, settings and profile changes invalidate it.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct CachedClasspath {
|
||||
fingerprint: LaunchFingerprint,
|
||||
classpath: String,
|
||||
}
|
||||
|
||||
/// A `.java` source and the module build that compiles it.
|
||||
pub struct SingleFileTarget {
|
||||
/// Absolute module directory.
|
||||
@@ -73,28 +64,6 @@ pub fn single_file_target(root: &Path, model: &BuildModel, file: &Path) -> IdeRe
|
||||
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
|
||||
}
|
||||
|
||||
/// The Maven reactor target for a launch module whose source file is not known yet.
|
||||
fn module_target(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<SingleFileTarget> {
|
||||
let Some(m) = &model.maven else {
|
||||
return Err(IdeError::not_found("no linked Maven project found for module"));
|
||||
};
|
||||
let module = if module.is_absolute() { module.to_path_buf() } else { root.join(module) };
|
||||
let project = m
|
||||
.projects
|
||||
.iter()
|
||||
.find(|p| root.join(&p.dir) == module)
|
||||
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
|
||||
if project.packaging == "pom" {
|
||||
return Err(IdeError::not_found("the launch module is a Maven aggregator without sources"));
|
||||
}
|
||||
let top = maven_reactor(m, &project.dir)
|
||||
.ok_or_else(|| IdeError::not_found("no linked Maven reactor found for module"))?;
|
||||
let top_dir = subdir(root, &top.dir);
|
||||
let pl = module.strip_prefix(&top_dir).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
|
||||
let out_dir = module.join("target").join(if test { "test-classes" } else { "classes" });
|
||||
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
|
||||
}
|
||||
|
||||
/// `javac` for the module: its `JAVA_HOME`, else `PATH`.
|
||||
pub fn find_javac(env: &BTreeMap<String, String>) -> String {
|
||||
let home = env.get("JAVA_HOME").map(Path::new);
|
||||
@@ -109,88 +78,6 @@ pub fn find_javac(env: &BTreeMap<String, String>) -> String {
|
||||
/// `mvn dependency:build-classpath` (which resolves without compiling anything,
|
||||
/// so `-am` stays cheap). Entries are platform-separated (`;` on Windows).
|
||||
pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFileTarget) -> IdeResult<String> {
|
||||
classpath(root, model, target, "test", false)
|
||||
}
|
||||
|
||||
/// Runtime (or test) dependency classpath for a launch module.
|
||||
///
|
||||
/// The Java language server runs on its own JDK, so its Maven model can select a
|
||||
/// different JDK profile and dependency classifier than the project launch. Use the
|
||||
/// same Maven process that builds the module instead.
|
||||
pub fn module_classpath(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
cache: &Path,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
let target = module_target(root, model, module, test)?;
|
||||
let fingerprint = launch_fingerprint(root, model, &target.module, test, local_repo, cancel)?;
|
||||
let cache_path = classpath_cache_path(root, cache, &target.module, test);
|
||||
if let (Some(fingerprint), Some(path)) = (&fingerprint, &cache_path)
|
||||
&& let Ok(bytes) = fs::read(path)
|
||||
&& let Ok(cached) = serde_json::from_slice::<CachedClasspath>(&bytes)
|
||||
&& cached.fingerprint == *fingerprint
|
||||
&& classpath_entries_exist(root, &cached.classpath)
|
||||
{
|
||||
return Ok(cached.classpath);
|
||||
}
|
||||
let classpath = classpath(root, model, &target, if test { "test" } else { "runtime" }, true)?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if let (Some(fingerprint), Some(path)) = (fingerprint, cache_path) {
|
||||
let cached = CachedClasspath { fingerprint, classpath: classpath.clone() };
|
||||
if let Err(error) = cache_classpath(&path, &cached) {
|
||||
tracing::debug!(path = %path.display(), %error, "Cannot cache the Java launch classpath");
|
||||
}
|
||||
}
|
||||
Ok(classpath)
|
||||
}
|
||||
|
||||
/// One cache file per module and scope. Mirroring the module path keeps nested
|
||||
/// modules and their different dependency scopes from overwriting each other.
|
||||
fn classpath_cache_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option<PathBuf> {
|
||||
let relative = module.strip_prefix(root).ok()?;
|
||||
let mut path = cache.join("java-launch/classpaths");
|
||||
path.push(if relative.as_os_str().is_empty() { Path::new("_root") } else { relative });
|
||||
path.push(if test { "test.json" } else { "runtime.json" });
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Best-effort cache persistence. A failed write only costs one later Maven
|
||||
/// probe, so the launch still succeeds even when the cache directory is unusable.
|
||||
fn cache_classpath(path: &Path, cached: &CachedClasspath) -> IdeResult<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, serde_json::to_vec(cached).map_err(|e| IdeError::internal(e.to_string()))?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A cache entry stays valid while every file exists. Maven also emits reactor
|
||||
/// output directories for modules with no classes, which may legitimately never be
|
||||
/// created; the launch snapshot already supplies the module's classes in that case.
|
||||
fn classpath_entries_exist(root: &Path, classpath: &str) -> bool {
|
||||
// Temp directories on macOS are commonly reached through `/var`, while Maven
|
||||
// reports the canonical `/private/var` path. Compare against both spellings.
|
||||
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
std::env::split_paths(classpath).all(|entry| {
|
||||
entry.exists()
|
||||
|| ((entry.starts_with(root) || entry.starts_with(&canonical_root))
|
||||
&& (entry.ends_with(Path::new("target/classes")) || entry.ends_with(Path::new("target/test-classes"))))
|
||||
})
|
||||
}
|
||||
|
||||
fn classpath(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
target: &SingleFileTarget,
|
||||
scope: &str,
|
||||
compile_first: bool,
|
||||
) -> IdeResult<String> {
|
||||
let mut entries: Vec<String> = Vec::new();
|
||||
// The module's own classes first: the file almost always uses classes next to it.
|
||||
let own: &[&str] = if target.test { &["target/test-classes", "target/classes"] } else { &["target/classes"] };
|
||||
@@ -223,14 +110,7 @@ fn classpath(
|
||||
args.extend(["-pl", target.pl.as_str(), "-am"]);
|
||||
}
|
||||
let output_arg = format!("-Dmdep.outputFile={probe}");
|
||||
let scope_arg = format!("-Dmdep.includeScope={scope}");
|
||||
// A launch build may only have reached `compile`. Running `compile` in this
|
||||
// same Maven session lets the dependency plugin resolve Reactor siblings from
|
||||
// their target/classes without paying for package-phase plugins.
|
||||
if compile_first {
|
||||
args.push("compile");
|
||||
}
|
||||
args.extend(["dependency:build-classpath", output_arg.as_str(), scope_arg.as_str(), "-q"]);
|
||||
args.extend(["dependency:build-classpath", output_arg.as_str(), "-Dmdep.includeScope=test", "-q"]);
|
||||
let top_dir = subdir(root, &target.top.dir);
|
||||
let status = ProcCommand::new(&target.top.command)
|
||||
.args(&args)
|
||||
@@ -239,10 +119,8 @@ fn classpath(
|
||||
.output()
|
||||
.map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?;
|
||||
if !status.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&status.stdout);
|
||||
let stderr = String::from_utf8_lossy(&status.stderr);
|
||||
// Maven reports reactor/dependency failures on stdout, not only stderr.
|
||||
let tail: Vec<&str> = stdout.lines().chain(stderr.lines()).filter(|l| !l.trim().is_empty()).collect();
|
||||
let tail: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
let tail = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n");
|
||||
return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
|
||||
}
|
||||
@@ -427,7 +305,7 @@ mod tests {
|
||||
let script = root.join("mvnw");
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
|
||||
"#!/bin/sh\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
@@ -449,90 +327,5 @@ mod tests {
|
||||
assert!(cp.contains("/repo/a.jar"), "{cp}");
|
||||
assert!(cp.contains("/repo/b.jar"), "{cp}");
|
||||
assert!(cp.split(separator).count() >= 3, "{cp}");
|
||||
let args = fs::read_to_string(root.join("args.txt")).unwrap();
|
||||
assert!(!args.lines().any(|a| a == "compile" || a == "package"), "{args}");
|
||||
}
|
||||
|
||||
/// A launch uses runtime dependencies, while tests additionally see test-scope ones.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn launch_module_classpath_uses_the_matching_dependency_scope() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
fixture(root);
|
||||
let script = root.join("mvnw");
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/runtime.jar:/repo/test.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let model = crate::load(root, &root.join("cache"), None);
|
||||
|
||||
let cache = root.join("cache");
|
||||
let runtime =
|
||||
module_classpath(root, &model, &root.join("app"), false, None, &cache, &CancellationToken::new()).unwrap();
|
||||
assert!(runtime.contains("/repo/runtime.jar"), "{runtime}");
|
||||
let args = fs::read_to_string(root.join("args.txt")).unwrap();
|
||||
assert!(args.lines().any(|a| a == "compile"), "{args}");
|
||||
assert!(!args.lines().any(|a| a == "package"), "{args}");
|
||||
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=runtime"), "{args}");
|
||||
|
||||
let test =
|
||||
module_classpath(root, &model, &root.join("app"), true, None, &cache, &CancellationToken::new()).unwrap();
|
||||
assert!(test.contains("/repo/test.jar"), "{test}");
|
||||
let args = fs::read_to_string(root.join("args.txt")).unwrap();
|
||||
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=test"), "{args}");
|
||||
}
|
||||
|
||||
/// The launch classpath follows the Maven runner JDK and is reused until that
|
||||
/// build input changes; the probe does not run packaging plugins.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn launch_classpath_cache_follows_the_runner_jdk() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
fixture(root);
|
||||
let script = root.join("mvnw");
|
||||
// `dependency:build-classpath` reports reactor outputs as absolute paths. Keep
|
||||
// the fake probe faithful so the cache's existence check sees the output.
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\nprintf x >> calls.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target/classes\" \"$JAVA_HOME/classpath\"\n: > \"$JAVA_HOME/classpath/jdk.jar\"\nprintf '%s/classpath/jdk.jar:%s/%s/target/classes:%s/empty/target/classes' \"$JAVA_HOME\" \"$(pwd)\" \"$pl\" \"$(pwd)\" > \"$pl/target/ide-compile-cp.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut model = crate::load(root, &root.join("cache"), None);
|
||||
for (name, version) in [("jdk21", "21"), ("jdk8", "8")] {
|
||||
let home = root.join(name);
|
||||
fs::create_dir_all(home.join("bin")).unwrap();
|
||||
fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
|
||||
fs::write(home.join("bin/java"), "").unwrap();
|
||||
}
|
||||
let cache = root.join("classpath-cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let jdk21 = root.join("jdk21");
|
||||
for project in &mut model.maven.as_mut().unwrap().projects {
|
||||
project.env.insert("JAVA_HOME".into(), jdk21.to_string_lossy().into_owned());
|
||||
}
|
||||
let first = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
|
||||
assert!(first.contains("jdk21/classpath/jdk.jar"), "{first}");
|
||||
assert!(!first.contains("jdk8/classpath/jdk.jar"), "{first}");
|
||||
let second = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
|
||||
assert_eq!(second, first);
|
||||
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "x");
|
||||
|
||||
let jdk8 = root.join("jdk8");
|
||||
for project in &mut model.maven.as_mut().unwrap().projects {
|
||||
project.env.insert("JAVA_HOME".into(), jdk8.to_string_lossy().into_owned());
|
||||
}
|
||||
let switched = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
|
||||
assert!(switched.contains("jdk8/classpath/jdk.jar"), "{switched}");
|
||||
assert!(!switched.contains("jdk21/classpath/jdk.jar"), "{switched}");
|
||||
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "xx");
|
||||
}
|
||||
}
|
||||
+23
-209
@@ -16,7 +16,6 @@ use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
pub mod completion;
|
||||
pub mod sources;
|
||||
|
||||
/// Lets the IDE's own Maven runs (Reload, Build) use what the local repository already
|
||||
@@ -300,7 +299,7 @@ fn read_plugin(e: &Element) -> Option<RawPlugin> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn read_properties(e: Option<&Element>) -> Vec<(String, String)> {
|
||||
fn read_properties(e: Option<&Element>) -> Vec<(String, String)> {
|
||||
e.map(|p| p.children.iter().map(|c| (c.name.clone(), c.text.trim().to_owned())).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -712,27 +711,18 @@ pub(crate) fn launch_inputs(
|
||||
// Profile activation may depend on the presence of an otherwise unrelated file.
|
||||
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
|
||||
if input || activation {
|
||||
// A source root under Maven's build directory is generated by the build itself.
|
||||
// Skip it without resolving the property: `<build><directory>` may be custom, and
|
||||
// hashing its rewritten files would make every launch look changed.
|
||||
if !(input && el.text.contains("${project.build.directory}")) {
|
||||
// Other properties can be overridden by -D, profiles or settings. Without
|
||||
// Maven's effective model, leave them unresolved to conservatively disable reuse.
|
||||
let value = el
|
||||
.text
|
||||
.trim()
|
||||
.replace("${project.basedir}", &dir.to_string_lossy())
|
||||
.replace("${basedir}", &dir.to_string_lossy());
|
||||
if value.contains("${") {
|
||||
return None;
|
||||
}
|
||||
if !value.is_empty() {
|
||||
let path = dir.join(value);
|
||||
// Conventional generated sources under target are also build outputs.
|
||||
if !(input && path.starts_with(dir.join("target"))) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
// Other properties can be overridden by -D, profiles or settings.
|
||||
// Without Maven's effective roots, conservatively disable reuse for them.
|
||||
let value = el
|
||||
.text
|
||||
.trim()
|
||||
.replace("${project.basedir}", &dir.to_string_lossy())
|
||||
.replace("${basedir}", &dir.to_string_lossy());
|
||||
if value.contains("${") {
|
||||
return None;
|
||||
}
|
||||
if !value.is_empty() {
|
||||
out.push(dir.join(value));
|
||||
}
|
||||
}
|
||||
for child in &el.children {
|
||||
@@ -1689,15 +1679,6 @@ pub enum PomGotoKind {
|
||||
ManagedDependency,
|
||||
/// A `<pluginManagement>` entry providing the plugin's version.
|
||||
ManagedPlugin,
|
||||
/// The POM of the artifact itself: a project module's, else the local repository's.
|
||||
ArtifactPom,
|
||||
}
|
||||
|
||||
/// A Maven project of the workspace, for going to a dependency on it.
|
||||
pub struct ModulePom {
|
||||
pub group_id: String,
|
||||
pub artifact_id: String,
|
||||
pub pom: PathBuf,
|
||||
}
|
||||
|
||||
/// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`.
|
||||
@@ -1748,7 +1729,7 @@ fn property_ref_at(text: &str, at: usize) -> Option<(&str, usize, usize)> {
|
||||
}
|
||||
|
||||
/// The elements containing `at`, from the root down to the innermost one.
|
||||
pub(crate) fn element_path(root: &Element, at: usize) -> Vec<&Element> {
|
||||
fn element_path(root: &Element, at: usize) -> Vec<&Element> {
|
||||
let mut path = vec![root];
|
||||
loop {
|
||||
let Some(next) = path.last().unwrap().children.iter().find(|c| c.start <= at && at < c.end) else {
|
||||
@@ -1777,20 +1758,6 @@ fn coordinates(e: &Element, props: &HashMap<String, String>) -> Option<(String,
|
||||
/// the local repository when it is not in the project). `None` when there is no
|
||||
/// resolvable declaration here.
|
||||
pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<PathBuf>) -> Option<PomGoto> {
|
||||
goto_declaration_in(text, path, pos, local_repo, Vec::new)
|
||||
}
|
||||
|
||||
/// [`goto_declaration`], where a `<parent>` and a dependency or plugin whose version no
|
||||
/// management entry provides (or a management entry itself) go to the artifact's own
|
||||
/// POM: the project module among `modules` with its coordinates, else the local
|
||||
/// repository's POM of its version.
|
||||
pub fn goto_declaration_in(
|
||||
text: &str,
|
||||
path: &Path,
|
||||
pos: Pos,
|
||||
local_repo: Option<PathBuf>,
|
||||
modules: impl FnOnce() -> Vec<ModulePom>,
|
||||
) -> Option<PomGoto> {
|
||||
let root = xml::parse(text).filter(|e| e.name == "project")?;
|
||||
let at = offset_at(text, pos);
|
||||
if let Some((name, _, _)) = property_ref_at(text, at)
|
||||
@@ -1801,56 +1768,15 @@ pub fn goto_declaration_in(
|
||||
// A `<dependency>` / `<plugin>` in a declaration position: its version may come
|
||||
// from a management entry of this POM or an ancestor's.
|
||||
let elements = element_path(&root, at);
|
||||
if elements.get(1).is_some_and(|e| e.name == "parent") {
|
||||
let chain = parent_chain(path, text, local_repo);
|
||||
let (pom, parent_text) = chain.get(1)?;
|
||||
let label = elements[1].text_of("artifactId").unwrap_or_default();
|
||||
return Some(pom_goto(pom.clone(), parent_text, label));
|
||||
}
|
||||
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
|
||||
// A management entry declares the version: it goes to the artifact (a BOM import, say).
|
||||
if !elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
|
||||
let kind = match e.name.as_str() {
|
||||
"dependency" => PomGotoKind::ManagedDependency,
|
||||
_ => PomGotoKind::ManagedPlugin,
|
||||
};
|
||||
if let Some(found) = managed_target(text, path, e, kind, local_repo.clone()) {
|
||||
return Some(found);
|
||||
}
|
||||
if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
|
||||
return None; // Management entries are declarations; there is nothing to go to.
|
||||
}
|
||||
artifact_target(text, path, e, local_repo, modules)
|
||||
}
|
||||
|
||||
/// The artifact's own POM for a dependency or plugin element `e`.
|
||||
fn artifact_target(
|
||||
text: &str,
|
||||
path: &Path,
|
||||
e: &Element,
|
||||
local_repo: Option<PathBuf>,
|
||||
modules: impl FnOnce() -> Vec<ModulePom>,
|
||||
) -> Option<PomGoto> {
|
||||
let mut resolver = Resolver::new(local_repo.clone(), &[]);
|
||||
let chain = parent_chain(path, text, local_repo);
|
||||
let props = properties_at(&mut resolver, &chain, 0);
|
||||
let (group, artifact) = coordinates(e, &props)?;
|
||||
let label = format!("{group}:{artifact}");
|
||||
if let Some(module) = modules().into_iter().find(|m| m.group_id == group && m.artifact_id == artifact) {
|
||||
let module_text = xml::read_text(&module.pom)?;
|
||||
return Some(pom_goto(module.pom, &module_text, label));
|
||||
}
|
||||
let version = interpolate(&e.text_of("version")?, &props);
|
||||
let pom = resolver.repo_pom(&group, &artifact, &version)?;
|
||||
let pom_text = xml::read_text(&pom)?;
|
||||
Some(pom_goto(pom, &pom_text, label))
|
||||
}
|
||||
|
||||
/// The POM at `path` as a target, at its `<artifactId>`.
|
||||
fn pom_goto(path: PathBuf, text: &str, label: String) -> PomGoto {
|
||||
let range = xml::parse(text)
|
||||
.filter(|e| e.name == "project")
|
||||
.and_then(|root| root.child("artifactId").map(|a| range_of(text, a)))
|
||||
.unwrap_or_default();
|
||||
PomGoto { path, range, kind: PomGotoKind::ArtifactPom, label }
|
||||
let kind = match e.name.as_str() {
|
||||
"dependency" => PomGotoKind::ManagedDependency,
|
||||
_ => PomGotoKind::ManagedPlugin,
|
||||
};
|
||||
managed_target(text, path, e, kind, local_repo)
|
||||
}
|
||||
|
||||
/// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
|
||||
@@ -2149,52 +2075,6 @@ mod tests {
|
||||
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_inputs_skip_generated_sources_under_the_project_build_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>\
|
||||
<packaging>pom</packaging><modules><module>app</module></modules></project>",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"app/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>app</artifactId><build><plugins><plugin>
|
||||
<groupId>org.codehaus.mojo</groupId><artifactId>build-helper-maven-plugin</artifactId>
|
||||
<executions><execution><configuration><sources>
|
||||
<source>${project.build.directory}/generated-sources/java</source>
|
||||
</sources></configuration></execution></executions>
|
||||
</plugin></plugins></build></project>"#,
|
||||
);
|
||||
let model = load_with_repo(root, None);
|
||||
let projects: Vec<&MavenProject> = model.projects.iter().collect();
|
||||
let inputs = launch_inputs(root, &projects, None).expect("portable launch inputs");
|
||||
assert!(!inputs.contains(&root.join("app/target/generated-sources/java")), "{inputs:?}");
|
||||
assert!(inputs.contains(&root.join("app/src")), "conventional sources stay fingerprinted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_inputs_disable_reuse_for_file_activation_on_the_build_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version>
|
||||
<build><directory>${project.basedir}/custom-build</directory></build>
|
||||
<profiles><profile><id>generated</id><activation><file>
|
||||
<exists>${project.build.directory}/enable-profile</exists>
|
||||
</file></activation></profile></profiles></project>"#,
|
||||
);
|
||||
let model = load_with_repo(root, None);
|
||||
let projects: Vec<&MavenProject> = model.projects.iter().collect();
|
||||
assert!(launch_inputs(root, &projects, None).is_none());
|
||||
}
|
||||
|
||||
fn write(dir: &Path, rel: &str, text: &str) {
|
||||
let p = dir.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
@@ -3206,9 +3086,9 @@ mod tests {
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
// The property is declared nowhere along the chain.
|
||||
assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None);
|
||||
// The dependency names its own version and no local repository has its POM.
|
||||
// The dependency names its own version: it is the declaration.
|
||||
assert_eq!(goto_declaration(&text, &path, at(&text, "<artifactId>other-lib"), None), None);
|
||||
// A management entry declares the version; its artifact's POM is not at hand either.
|
||||
// A management entry is a declaration itself, not a reference.
|
||||
let parent_path = tmp.path().join("pom.xml");
|
||||
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
|
||||
assert_eq!(
|
||||
@@ -3220,72 +3100,6 @@ mod tests {
|
||||
assert_eq!(goto_declaration("<project></project>", &path, Pos::new(0, 3), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifacts_go_to_their_own_pom_in_the_project_or_the_local_repository() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
let bom_dir = repo.join("com/alibaba/cloud/spring-cloud-alibaba-dependencies/2.2.6.RELEASE");
|
||||
std::fs::create_dir_all(&bom_dir).unwrap();
|
||||
let bom = "<project>\n <groupId>com.alibaba.cloud</groupId>\n <artifactId>spring-cloud-alibaba-dependencies</artifactId>\n</project>";
|
||||
std::fs::write(bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"), bom).unwrap();
|
||||
let parent = format!(
|
||||
r#"{PARENT_HEADER}<properties><alibaba.version>2.2.6.RELEASE</alibaba.version></properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
|
||||
<version>${{alibaba.version}}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</project>"#
|
||||
);
|
||||
let child = format!(
|
||||
r#"{CHILD_HEADER}<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>1.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>"#
|
||||
);
|
||||
write_parent_child(tmp.path(), &parent, &child);
|
||||
let core = tmp.path().join("core/pom.xml");
|
||||
std::fs::create_dir_all(core.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&core,
|
||||
"<project>\n <groupId>com.example</groupId>\n <artifactId>core</artifactId>\n</project>",
|
||||
)
|
||||
.unwrap();
|
||||
let modules =
|
||||
|| vec![ModulePom { group_id: "com.example".into(), artifact_id: "core".into(), pom: core.clone() }];
|
||||
|
||||
// A BOM import in <dependencyManagement> opens the BOM from the local repository.
|
||||
let parent_path = tmp.path().join("pom.xml");
|
||||
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
|
||||
let at_bom = at(&parent_text, "spring-cloud-alibaba-dependencies<");
|
||||
let goto = goto_declaration_in(&parent_text, &parent_path, at_bom, Some(repo.clone()), modules).unwrap();
|
||||
assert_eq!(goto.path, bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"));
|
||||
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
|
||||
assert_eq!(goto.range.start, pos_at(bom, bom.find("<artifactId>").unwrap()));
|
||||
|
||||
// A dependency on a project module opens the module's POM.
|
||||
let path = tmp.path().join("app/pom.xml");
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let goto =
|
||||
goto_declaration_in(&text, &path, at(&text, "core</artifactId>"), Some(repo.clone()), modules).unwrap();
|
||||
assert_eq!((goto.path.as_path(), goto.label.as_str()), (core.as_path(), "com.example:core"));
|
||||
|
||||
// The <parent> section opens the parent POM.
|
||||
let goto = goto_declaration_in(&text, &path, at(&text, "parent</artifactId>"), Some(repo), modules).unwrap();
|
||||
assert_eq!(goto.path, canon(&parent_path));
|
||||
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_property_reference_after_non_ascii_text_is_still_found() {
|
||||
let text = format!(
|
||||
|
||||
@@ -1,694 +0,0 @@
|
||||
//! Completion of Maven coordinates in a POM: inside a `<dependency>`, the
|
||||
//! `<groupId>`, `<artifactId>` and `<version>` values are completed from the
|
||||
//! local repository layout (`<repo>/<group path>/<artifactId>/<version>/…`).
|
||||
//! The Maven Central search endpoint tops the lists up remotely; the host op
|
||||
//! fetches those (cached) and merges them behind the local ones, so this side
|
||||
//! stays pure text and directory logic and tests build their own repositories.
|
||||
|
||||
use super::xml::{self, Element};
|
||||
use super::{element_path, interpolate, read_properties};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The coordinate element a POM completion fills.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PomCompletionKind {
|
||||
/// The dependency's `<groupId>`.
|
||||
GroupId,
|
||||
/// The dependency's `<artifactId>`.
|
||||
ArtifactId,
|
||||
/// The dependency's `<version>`.
|
||||
Version,
|
||||
}
|
||||
|
||||
/// One suggestion: the coordinate as inserted, and an optional note shown in
|
||||
/// the lookup (the owning group of an artifactId, its newest installed version).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PomCompletionOption {
|
||||
/// The text the completion inserts (`org.example.lib`, `example-lib`, `1.2.0`).
|
||||
pub label: String,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// The completion offered at a caret of a POM: the replaced value span (UTF-16
|
||||
/// offsets) and the suggestions, local ones first.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PomCompletion {
|
||||
/// UTF-16 offset where the completed text starts: the value's first character.
|
||||
pub from: u32,
|
||||
/// UTF-16 offset where the replaced value ends: past the caret when the caret
|
||||
/// sits inside an existing value, which is then replaced whole.
|
||||
pub to: u32,
|
||||
/// Which coordinate element the caret is in.
|
||||
pub kind: PomCompletionKind,
|
||||
/// The suggestions, local repository first.
|
||||
pub options: Vec<PomCompletionOption>,
|
||||
}
|
||||
|
||||
/// Where the caret sits in a `<dependency>` coordinate element: what is being
|
||||
/// completed, the typed prefix, and the sibling coordinates the rest need.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PomCompletionTarget {
|
||||
/// UTF-16 offset of the value's first character (the caret in an empty value).
|
||||
pub from: u32,
|
||||
/// UTF-16 offset past the value's last character (the caret in an empty value).
|
||||
pub to: u32,
|
||||
/// The value typed so far: the text between `from` and the caret, trimmed.
|
||||
pub typed: String,
|
||||
pub kind: PomCompletionKind,
|
||||
/// The interpolated `<groupId>` sibling, when completing the artifactId and
|
||||
/// the group is written out plain (no unresolved `${property}`).
|
||||
pub group: Option<String>,
|
||||
/// The interpolated `<artifactId>` sibling, when completing the version.
|
||||
pub artifact: Option<String>,
|
||||
}
|
||||
|
||||
/// A coordinate element's text, with the POM's own `${properties}` replaced;
|
||||
/// `None` when the element is missing or a property stays unresolved (the
|
||||
/// coordinates behind it are not known without the parent chain, which the
|
||||
/// completion does not follow).
|
||||
fn coordinate(e: Option<&Element>, props: &HashMap<String, String>) -> Option<String> {
|
||||
let text = interpolate(e?.text.trim(), props);
|
||||
(!text.is_empty() && !text.contains("${")).then_some(text)
|
||||
}
|
||||
|
||||
fn own_properties(root: &Element) -> HashMap<String, String> {
|
||||
read_properties(root.child("properties")).into_iter().collect()
|
||||
}
|
||||
|
||||
/// UTF-16 offset -> byte offset, clamped to `text`.
|
||||
fn utf16_to_byte(text: &str, offset: u32) -> usize {
|
||||
let mut seen = 0u32;
|
||||
for (byte, c) in text.char_indices() {
|
||||
if seen >= offset {
|
||||
return byte;
|
||||
}
|
||||
seen += c.len_utf16() as u32;
|
||||
}
|
||||
text.len()
|
||||
}
|
||||
|
||||
/// Byte offset -> UTF-16 offset.
|
||||
fn utf16_at(text: &str, byte: usize) -> u32 {
|
||||
text[..byte].encode_utf16().count() as u32
|
||||
}
|
||||
|
||||
/// Byte offset just past the start tag's `>` of the element at `start`.
|
||||
fn start_tag_end(text: &str, start: usize) -> Option<usize> {
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = start.checked_add(1)?;
|
||||
let mut quote = None;
|
||||
while i < bytes.len() {
|
||||
match (quote, bytes[i]) {
|
||||
(Some(q), b) if b == q => quote = None,
|
||||
(Some(_), _) => {}
|
||||
(None, b'"' | b'\'') => quote = Some(bytes[i]),
|
||||
(None, b'>') => return Some(i + 1),
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// What to complete at the caret (`offset`, UTF-16) of the POM `text`: the
|
||||
/// `<groupId>` / `<artifactId>` / `<version>` element of a `<dependency>` the
|
||||
/// caret sits in the value of. `None` everywhere else — other elements, carets
|
||||
/// in an end tag or a `${property}` reference, dependencies without the
|
||||
/// `<groupId>` (and, for versions, the `<artifactId>`) their completions need.
|
||||
pub fn completion_target(text: &str, offset: u32) -> Option<PomCompletionTarget> {
|
||||
let root = xml::parse(text).filter(|e| e.name == "project")?;
|
||||
let at = utf16_to_byte(text, offset);
|
||||
// A caret at the very end of the input sits in the element still being
|
||||
// typed, whose range reaches to the input's end; step back one character
|
||||
// so `element_path` sees it (the caret's own offset stays `at`).
|
||||
let probe = if at == text.len() && at > 0 {
|
||||
let mut p = at - 1;
|
||||
while p > 0 && !text.is_char_boundary(p) {
|
||||
p -= 1;
|
||||
}
|
||||
p
|
||||
} else {
|
||||
at
|
||||
};
|
||||
let elements = element_path(&root, probe);
|
||||
let e = *elements.last()?;
|
||||
let parent = elements.get(elements.len().checked_sub(2)?)?;
|
||||
let kind = match e.name.as_str() {
|
||||
"groupId" => PomCompletionKind::GroupId,
|
||||
"artifactId" => PomCompletionKind::ArtifactId,
|
||||
"version" => PomCompletionKind::Version,
|
||||
_ => return None,
|
||||
};
|
||||
if parent.name != "dependency" || at > e.inner_end {
|
||||
return None;
|
||||
}
|
||||
let inner_start = start_tag_end(text, e.start)?;
|
||||
let value = &text[inner_start..e.inner_end.max(inner_start)];
|
||||
if value.contains("${") {
|
||||
return None; // Property references are goto-declaration territory, not completion.
|
||||
}
|
||||
let lead = value.len() - value.trim_start().len();
|
||||
let trail = value.len() - value.trim_end().len();
|
||||
let (mut from, mut to) = (inner_start + lead, e.inner_end.max(inner_start) - trail);
|
||||
if from > to {
|
||||
// A value of whitespace only: complete at the caret.
|
||||
from = at.min(to);
|
||||
to = to.max(from);
|
||||
}
|
||||
from = from.min(at);
|
||||
to = to.max(from);
|
||||
let props = own_properties(&root);
|
||||
let group = (kind != PomCompletionKind::GroupId).then(|| coordinate(parent.child("groupId"), &props)).flatten();
|
||||
let artifact =
|
||||
(kind == PomCompletionKind::Version).then(|| coordinate(parent.child("artifactId"), &props)).flatten();
|
||||
let typed = text[from..at].trim().to_owned();
|
||||
Some(PomCompletionTarget { from: utf16_at(text, from), to: utf16_at(text, to), typed, kind, group, artifact })
|
||||
}
|
||||
|
||||
/// The suggestions the local repository at `repo` offers for `target`: group
|
||||
/// ids installed under it, the artifacts of the target's group, or the versions
|
||||
/// of the target's artifact. Every scan is bounded (see `SCAN_BUDGET`) and
|
||||
/// reads only directory listings of `repo` — nothing outside it, no jar
|
||||
/// contents.
|
||||
pub fn local_options(repo: &Path, target: &PomCompletionTarget) -> Vec<PomCompletionOption> {
|
||||
let mut budget = SCAN_BUDGET;
|
||||
match target.kind {
|
||||
// `org.spr` completes the `spr` part under `org/`, `org.` the next
|
||||
// segment under `org/`; every typed segment is validated so POM text
|
||||
// cannot walk out of the repository.
|
||||
PomCompletionKind::GroupId => {
|
||||
let segments: Vec<&str> = target.typed.split('.').collect();
|
||||
let (partial, base) = segments.split_last().unwrap_or((&"", &[]));
|
||||
if base.iter().all(|s| segment(s)) {
|
||||
let dir = base.iter().fold(repo.to_path_buf(), |p, s| p.join(s));
|
||||
scan_groups(&dir, &base.join("."), partial, &mut budget)
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
PomCompletionKind::ArtifactId => target
|
||||
.group
|
||||
.as_deref()
|
||||
.and_then(|g| repo_dir(repo, &[g]))
|
||||
.map_or_else(Vec::new, |dir| scan_artifacts(&dir, &target.typed, &mut budget)),
|
||||
PomCompletionKind::Version => target
|
||||
.group
|
||||
.as_deref()
|
||||
.zip(target.artifact.as_deref())
|
||||
.and_then(|(g, a)| repo_dir(repo, &[g, a]))
|
||||
.map_or_else(Vec::new, |dir| scan_versions(&dir, &target.typed, &mut budget)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory listings one completion may read: the scans are interactive, so
|
||||
/// they stop before they could be felt, and a huge repository simply yields
|
||||
/// fewer suggestions (its shallowest first — the walk is breadth-preferring).
|
||||
const SCAN_BUDGET: u32 = 2048;
|
||||
/// Suggestion caps per kind: group ids and artifact ids fill a lookup page,
|
||||
/// versions list an artifact's releases.
|
||||
const MAX_GROUP_OPTIONS: usize = 60;
|
||||
const MAX_ARTIFACT_OPTIONS: usize = 60;
|
||||
const MAX_VERSION_OPTIONS: usize = 100;
|
||||
/// The cap the merged list (local plus Maven Central's) is truncated to.
|
||||
pub const MAX_OPTIONS: usize = 100;
|
||||
|
||||
/// A directory entry whose name passes the repository layout's alphabet
|
||||
/// (mirrors `sources::artifact`'s): letters, digits, `._-`. Path separators
|
||||
/// and `..` never pass, so a POM's `${…}`-free text cannot traverse out of
|
||||
/// the repository.
|
||||
fn segment(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
|
||||
}
|
||||
|
||||
/// The subdirectories of `dir` with repository-layout names, spending `budget`
|
||||
/// on the listing; `None` when the budget ran out before `dir` was listed.
|
||||
fn subdirs(dir: &Path, budget: &mut u32) -> Option<Vec<String>> {
|
||||
if *budget == 0 {
|
||||
return None;
|
||||
}
|
||||
*budget -= 1;
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Some(Vec::new());
|
||||
};
|
||||
let mut names = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
// The budget bounds file-type checks too: a plain listing answers.
|
||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false)
|
||||
&& let Ok(name) = entry.file_name().into_string()
|
||||
&& segment(&name)
|
||||
{
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
Some(names)
|
||||
}
|
||||
|
||||
/// Whether `dir` holds files — a version directory does (jar, pom, the
|
||||
/// tracking files), a group path segment never does.
|
||||
fn holds_files(dir: &Path, budget: &mut u32) -> bool {
|
||||
if *budget == 0 {
|
||||
return false;
|
||||
}
|
||||
*budget -= 1;
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Whether the version directory `dir` holds the version: its `.pom` or a `.jar`. A failed
|
||||
/// download leaves only `*.lastUpdated` tracking files behind, for a version that may not
|
||||
/// even exist (one typed while the POM was being edited).
|
||||
fn installed(dir: &Path, budget: &mut u32) -> bool {
|
||||
if *budget == 0 {
|
||||
return false;
|
||||
}
|
||||
*budget -= 1;
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| {
|
||||
e.file_type().map(|t| t.is_file()).unwrap_or(false)
|
||||
&& e.file_name().to_str().is_some_and(|n| n.ends_with(".pom") || n.ends_with(".jar"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `dir` is an artifact directory: it holds versions of one artifact,
|
||||
/// recognizable by a `maven-metadata*.xml` beside them or a child directory
|
||||
/// holding files. A group segment's children are directories without files.
|
||||
fn artifact_dir(dir: &Path, budget: &mut u32) -> bool {
|
||||
if *budget == 0 {
|
||||
return false;
|
||||
}
|
||||
*budget -= 1;
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Ok(name) = entry.file_name().into_string() else { continue };
|
||||
if name.starts_with("maven-metadata") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let Some(children) = subdirs(dir, budget) else { return false };
|
||||
children.iter().any(|c| holds_files(&dir.join(c), budget))
|
||||
}
|
||||
|
||||
/// Whether `dir` is a group directory (holds artifact directories) and whether
|
||||
/// it also holds deeper group segments worth descending into.
|
||||
fn group_dir(dir: &Path, budget: &mut u32) -> Option<(bool, bool)> {
|
||||
let children = subdirs(dir, budget)?;
|
||||
let (mut group, mut descend) = (false, false);
|
||||
for child in &children {
|
||||
if artifact_dir(&dir.join(child), budget) {
|
||||
group = true;
|
||||
} else {
|
||||
descend = true;
|
||||
}
|
||||
}
|
||||
Some((group, descend))
|
||||
}
|
||||
|
||||
/// The group ids installed under `dir` (which the typed prefix `prefix` —
|
||||
/// "" for the repository root — already names), completing the partial last
|
||||
/// segment `partial`, shallower groups first.
|
||||
fn scan_groups(dir: &Path, prefix: &str, partial: &str, budget: &mut u32) -> Vec<PomCompletionOption> {
|
||||
let mut out = Vec::new();
|
||||
scan_groups_into(dir, prefix, partial, &mut out, budget);
|
||||
out.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
out
|
||||
}
|
||||
|
||||
fn scan_groups_into(dir: &Path, prefix: &str, partial: &str, out: &mut Vec<PomCompletionOption>, budget: &mut u32) {
|
||||
if out.len() >= MAX_GROUP_OPTIONS {
|
||||
return;
|
||||
}
|
||||
let Some(children) = subdirs(dir, budget) else { return };
|
||||
for name in children {
|
||||
if !name.starts_with(partial) {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(&name);
|
||||
let label = if prefix.is_empty() { name.clone() } else { format!("{prefix}.{name}") };
|
||||
let Some((group, descend)) = group_dir(&path, budget) else { return };
|
||||
if group {
|
||||
out.push(PomCompletionOption { label: label.clone(), detail: None });
|
||||
if out.len() >= MAX_GROUP_OPTIONS {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Deeper groups (org.example under org.example's own parent) follow the
|
||||
// shallower ones, so a truncated scan of a huge repository still offers
|
||||
// every first-level segment.
|
||||
if descend {
|
||||
scan_groups_into(&path, &label, "", out, budget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The artifact ids installed under the group directory `dir`, completing the
|
||||
/// typed prefix, each with its newest installed version as the detail.
|
||||
fn scan_artifacts(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletionOption> {
|
||||
let Some(children) = subdirs(dir, budget) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for name in children {
|
||||
if !name.starts_with(typed) || !artifact_dir(&dir.join(&name), budget) {
|
||||
continue;
|
||||
}
|
||||
let detail = newest_version(&dir.join(&name), budget);
|
||||
out.push(PomCompletionOption { label: name, detail });
|
||||
}
|
||||
out.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
out.truncate(MAX_ARTIFACT_OPTIONS);
|
||||
out
|
||||
}
|
||||
|
||||
/// The newest version directory of the artifact at `dir`, by Maven's ordering.
|
||||
fn newest_version(dir: &Path, budget: &mut u32) -> Option<String> {
|
||||
let children = subdirs(dir, budget)?;
|
||||
children.into_iter().filter(|name| installed(&dir.join(name), budget)).max_by(|a, b| compare_versions(a, b))
|
||||
}
|
||||
|
||||
/// The versions installed of the artifact at `dir`, completing the typed
|
||||
/// prefix, newest first.
|
||||
fn scan_versions(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletionOption> {
|
||||
let Some(children) = subdirs(dir, budget) else { return Vec::new() };
|
||||
let mut out: Vec<_> = children
|
||||
.into_iter()
|
||||
.filter(|name| name.starts_with(typed) && installed(&dir.join(name), budget))
|
||||
.map(|name| PomCompletionOption { label: name, detail: None })
|
||||
.collect();
|
||||
out.sort_by(|a, b| compare_versions(&b.label, &a.label));
|
||||
out.truncate(MAX_VERSION_OPTIONS);
|
||||
out
|
||||
}
|
||||
|
||||
/// The repository directory of `coordinates` (a group id, or a group and an artifact
|
||||
/// id): the group's dots are directories, an artifact id is one directory whatever dots
|
||||
/// it has (`org/eclipse/platform/org.eclipse.core.runtime`). Each segment is validated
|
||||
/// against the layout's alphabet so POM text cannot walk outside `repo`.
|
||||
fn repo_dir(repo: &Path, coordinates: &[&str]) -> Option<PathBuf> {
|
||||
let (group, artifact) = coordinates.split_first()?;
|
||||
let segments: Vec<&str> = group.split('.').chain(artifact.iter().copied()).collect();
|
||||
segments.iter().all(|s| segment(s)).then(|| segments.iter().fold(repo.to_path_buf(), |p, s| p.join(s)))
|
||||
}
|
||||
|
||||
/// Maven-ish version order, best-effort (a full `ComparableVersion` is out of
|
||||
/// proportion for ordering a lookup): numeric runs compare numerically, letter
|
||||
/// runs by their qualifier rank (`alpha < beta < m < rc < snapshot < release <
|
||||
/// sp`, unknown ones with releases lexically); missing parts rank as zero.
|
||||
fn compare_versions(a: &str, b: &str) -> Ordering {
|
||||
fn tokens(v: &str) -> Vec<String> {
|
||||
v.split(|c: char| !c.is_ascii_alphanumeric()).filter(|t| !t.is_empty()).map(str::to_ascii_lowercase).collect()
|
||||
}
|
||||
fn rank(t: &str) -> Option<u8> {
|
||||
match t {
|
||||
"a" | "alpha" => Some(0),
|
||||
"b" | "beta" => Some(1),
|
||||
"m" | "milestone" => Some(2),
|
||||
"rc" | "cr" => Some(3),
|
||||
"snapshot" => Some(4),
|
||||
"" | "ga" | "final" => Some(5),
|
||||
"sp" => Some(6),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let (a, b) = (tokens(a), tokens(b));
|
||||
for i in 0..a.len().max(b.len()) {
|
||||
let (x, y) = (a.get(i).map(String::as_str), b.get(i).map(String::as_str));
|
||||
let (xn, yn) = (x.and_then(|s| s.parse::<u64>().ok()), y.and_then(|s| s.parse::<u64>().ok()));
|
||||
let order = match (xn, yn) {
|
||||
// Numbers compare numerically; a number outranks a qualifier.
|
||||
(Some(xn), Some(yn)) => xn.cmp(&yn),
|
||||
(Some(_), None) if y.is_some() => return Ordering::Greater,
|
||||
(None, Some(_)) if x.is_some() => return Ordering::Less,
|
||||
_ => {
|
||||
let xr = x.and_then(rank).unwrap_or(5);
|
||||
let yr = y.and_then(rank).unwrap_or(5);
|
||||
if xr != yr { xr.cmp(&yr) } else { x.unwrap_or("").cmp(y.unwrap_or("")) }
|
||||
}
|
||||
};
|
||||
if order != Ordering::Equal {
|
||||
return order;
|
||||
}
|
||||
}
|
||||
Ordering::Equal
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A small local repository: every file is laid out as Maven installs it,
|
||||
/// so the scans recognize group, artifact and version directories.
|
||||
fn repo(dir: &Path) -> PathBuf {
|
||||
let repo = dir.join("repository");
|
||||
for file in [
|
||||
"org/apache/maven/maven-core/4.0.0-rc-5/maven-core-4.0.0-rc-5.jar",
|
||||
"org/apache/maven/plugins/maven-compiler-plugin/3.8.1/maven-compiler-plugin-3.8.1.jar",
|
||||
"org/example/lib/1.0/lib-1.0.jar",
|
||||
"org/example/lib/1.9.1/lib-1.9.1.jar",
|
||||
"org/example/lib/1.10/lib-1.10.jar",
|
||||
"org/example/lib/2.0-beta-1/lib-2.0-beta-1.jar",
|
||||
"org/example/lib/2.0/lib-2.0.pom",
|
||||
"net/nu/netty/4.1.100/netty-4.1.100.jar",
|
||||
"org/eclipse/platform/org.eclipse.core.runtime/3.31.0/org.eclipse.core.runtime-3.31.0.jar",
|
||||
// Failed downloads (a version typed while editing): tracking files only, not installed.
|
||||
"org/example/lib/2/lib-2.jar.lastUpdated",
|
||||
"org/example/lib/3.0/lib-3.0.pom.lastUpdated",
|
||||
] {
|
||||
let path = repo.join(file);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
}
|
||||
repo
|
||||
}
|
||||
|
||||
fn target(text: &str, needle: &str) -> Option<PomCompletionTarget> {
|
||||
completion_target(text, (text.find(needle).unwrap() + needle.len()) as u32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_each_coordinate_element_of_a_dependency() {
|
||||
let text = "<project><dependencies><dependency>\
|
||||
<groupId>org.example</groupId><artifactId>lib</artifactId><version>1.0</version>\
|
||||
</dependency></dependencies></project>";
|
||||
let t = target(text, "<groupId>org.example").unwrap();
|
||||
assert_eq!(t.kind, PomCompletionKind::GroupId);
|
||||
assert_eq!(t.typed, "org.example");
|
||||
assert_eq!((t.from, t.to), (44, 55));
|
||||
assert!(t.group.is_none() && t.artifact.is_none());
|
||||
|
||||
let t = target(text, "<artifactId>lib").unwrap();
|
||||
assert_eq!(t.kind, PomCompletionKind::ArtifactId);
|
||||
assert_eq!(t.typed, "lib");
|
||||
assert_eq!(t.group.as_deref(), Some("org.example"));
|
||||
assert!(t.artifact.is_none());
|
||||
|
||||
let t = target(text, "<version>1.0").unwrap();
|
||||
assert_eq!(t.kind, PomCompletionKind::Version);
|
||||
assert_eq!(t.typed, "1.0");
|
||||
assert_eq!(t.group.as_deref(), Some("org.example"));
|
||||
assert_eq!(t.artifact.as_deref(), Some("lib"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_while_the_element_is_being_typed_and_the_value_is_empty() {
|
||||
let empty = "<project><dependencies><dependency><groupId></groupId></dependency></dependencies></project>";
|
||||
let t = target(empty, "<groupId>").unwrap();
|
||||
assert_eq!(t.kind, PomCompletionKind::GroupId);
|
||||
assert_eq!(t.typed, "");
|
||||
assert_eq!(t.from, t.to);
|
||||
|
||||
let open = "<project><dependencies><dependency><artifactId>li";
|
||||
let t = target(open, "li").unwrap();
|
||||
assert_eq!(t.kind, PomCompletionKind::ArtifactId);
|
||||
assert_eq!(t.typed, "li");
|
||||
// The value under construction ends with the input and is replaced whole.
|
||||
assert_eq!(t.to, open.len() as u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_only_dependency_coordinates() {
|
||||
// The same elements outside a <dependency> get nothing: a <plugin> is
|
||||
// completed by the repository, not coordinates.
|
||||
let plugin =
|
||||
"<project><build><plugins><plugin><groupId>org.apache.maven</groupId></plugin></plugins></build></project>";
|
||||
assert_eq!(target(plugin, "<groupId>org.apache.maven"), None);
|
||||
// A caret in the end tag, or on a `${property}` value, is not a completion.
|
||||
let text =
|
||||
"<project><dependencies><dependency><groupId>org.example</groupId></dependency></dependencies></project>";
|
||||
assert_eq!(target(text, "</groupId>"), None);
|
||||
let property = "<project><dependencies><dependency><groupId>${example.group}</groupId></dependency></dependencies></project>";
|
||||
assert_eq!(target(property, "${example.group}"), None);
|
||||
// A dependency's groupId is required before its artifacts can be listed.
|
||||
let no_group =
|
||||
"<project><dependencies><dependency><artifactId>lib</artifactId></dependency></dependencies></project>";
|
||||
let t = target(no_group, "<artifactId>lib").unwrap();
|
||||
assert!(t.group.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolates_the_poms_own_properties_for_siblings() {
|
||||
let text = "<project>\
|
||||
<properties><example.group>org.example</example.group><example.version>2.0</example.version></properties>\
|
||||
<dependencies><dependency><groupId>${example.group}</groupId><artifactId>lib</artifactId><version>${example.version}</version></dependency></dependencies>\
|
||||
</project>";
|
||||
// The groupId the caret is typing stays a completion; the siblings resolve.
|
||||
let t = target(text, "<artifactId>lib").unwrap();
|
||||
assert_eq!(t.group.as_deref(), Some("org.example"));
|
||||
// A `${property}` under the caret itself gets nothing to complete.
|
||||
assert_eq!(target(text, "<version>${example.version}"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_scan_lists_installed_groups_shallowest_first() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = repo(tmp.path());
|
||||
let options = |typed: &str| {
|
||||
local_options(
|
||||
&repo,
|
||||
&PomCompletionTarget {
|
||||
from: 0,
|
||||
to: 0,
|
||||
typed: typed.into(),
|
||||
kind: PomCompletionKind::GroupId,
|
||||
group: None,
|
||||
artifact: None,
|
||||
},
|
||||
)
|
||||
.into_iter()
|
||||
.map(|o| o.label)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
// Only directories holding artifacts are group ids: `net` and `org`
|
||||
// alone never receive an install, their deeper levels do.
|
||||
let org = ["org.apache.maven", "org.apache.maven.plugins", "org.eclipse.platform", "org.example"];
|
||||
assert_eq!(options(""), [&["net.nu"][..], &org].concat());
|
||||
assert_eq!(options("org"), org);
|
||||
assert_eq!(options("org."), org);
|
||||
assert_eq!(options("org.spr"), Vec::<String>::new());
|
||||
assert_eq!(options("net."), ["net.nu"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_scan_lists_artifacts_of_the_group_with_their_newest_version() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = repo(tmp.path());
|
||||
let options = |typed: &str| {
|
||||
local_options(
|
||||
&repo,
|
||||
&PomCompletionTarget {
|
||||
from: 0,
|
||||
to: 0,
|
||||
typed: typed.into(),
|
||||
kind: PomCompletionKind::ArtifactId,
|
||||
group: Some("org.example".into()),
|
||||
artifact: None,
|
||||
},
|
||||
)
|
||||
.into_iter()
|
||||
.map(|o| (o.label, o.detail))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(options(""), [("lib".to_owned(), Some("2.0".to_owned()))]);
|
||||
assert_eq!(options("net"), Vec::<(String, Option<String>)>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_scan_lists_versions_of_the_artifact_newest_first() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = repo(tmp.path());
|
||||
let options = |typed: &str| {
|
||||
local_options(
|
||||
&repo,
|
||||
&PomCompletionTarget {
|
||||
from: 0,
|
||||
to: 0,
|
||||
typed: typed.into(),
|
||||
kind: PomCompletionKind::Version,
|
||||
group: Some("org.example".into()),
|
||||
artifact: Some("lib".into()),
|
||||
},
|
||||
)
|
||||
.into_iter()
|
||||
.map(|o| o.label)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(options(""), ["2.0", "2.0-beta-1", "1.10", "1.9.1", "1.0"]);
|
||||
assert_eq!(options("1."), ["1.10", "1.9.1", "1.0"]);
|
||||
// An artifact id with dots is one directory.
|
||||
let dotted = local_options(
|
||||
&repo,
|
||||
&PomCompletionTarget {
|
||||
from: 0,
|
||||
to: 0,
|
||||
typed: "".into(),
|
||||
kind: PomCompletionKind::Version,
|
||||
group: Some("org.eclipse.platform".into()),
|
||||
artifact: Some("org.eclipse.core.runtime".into()),
|
||||
},
|
||||
);
|
||||
assert_eq!(dotted.into_iter().map(|o| o.label).collect::<Vec<_>>(), ["3.31.0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poms_text_cannot_walk_out_of_the_repository() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = repo(tmp.path());
|
||||
for group in ["../../etc", "..", "org/../.."] {
|
||||
let options = local_options(
|
||||
&repo,
|
||||
&PomCompletionTarget {
|
||||
from: 0,
|
||||
to: 0,
|
||||
typed: "".into(),
|
||||
kind: PomCompletionKind::ArtifactId,
|
||||
group: Some(group.into()),
|
||||
artifact: None,
|
||||
},
|
||||
);
|
||||
assert!(options.is_empty(), "{group}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_stops_at_the_option_cap() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repository");
|
||||
for i in 0..MAX_GROUP_OPTIONS + 10 {
|
||||
let group = repo.join(format!("com/example/g{i}"));
|
||||
std::fs::create_dir_all(group.join("art/1.0")).unwrap();
|
||||
std::fs::write(group.join("art/1.0/art-1.0.jar"), b"").unwrap();
|
||||
}
|
||||
let options = local_options(
|
||||
&repo,
|
||||
&PomCompletionTarget {
|
||||
from: 0,
|
||||
to: 0,
|
||||
typed: "".into(),
|
||||
kind: PomCompletionKind::GroupId,
|
||||
group: None,
|
||||
artifact: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(options.len(), MAX_GROUP_OPTIONS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_version_order() {
|
||||
let mut versions = ["2.0", "1.0", "2.0-beta-1", "2.0-alpha-2", "1.10", "1.9.1", "4.0.0-rc-5", "4.0.0-rc-13"];
|
||||
versions.sort_by(|a, b| compare_versions(a, b));
|
||||
assert_eq!(versions, ["1.0", "1.9.1", "1.10", "2.0-alpha-2", "2.0-beta-1", "2.0", "4.0.0-rc-5", "4.0.0-rc-13"]);
|
||||
}
|
||||
}
|
||||
@@ -270,13 +270,6 @@ fn run(
|
||||
|
||||
/// The first error lines of a failed build, for the tool window.
|
||||
pub(crate) fn error_summary(text: &str) -> String {
|
||||
// The JVM's "could not find or load main class" (localized) when `gradlew` has no jar.
|
||||
if text.contains("org.gradle.wrapper.GradleWrapperMain") {
|
||||
return "gradle/wrapper/gradle-wrapper.jar is missing (often excluded by a `*.jar` rule in .gitignore). \
|
||||
Restore it, run `gradle wrapper` with an installed Gradle, or choose Distribution: Local installation \
|
||||
in Settings › Build, Execution, Deployment › Gradle."
|
||||
.to_owned();
|
||||
}
|
||||
let lines: Vec<&str> = text
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
@@ -916,8 +909,6 @@ mod tests {
|
||||
let text = "[INFO] x\n[ERROR] Failed to execute goal\n[ERROR] \n[ERROR] Re-run Maven using -X\n";
|
||||
assert_eq!(error_summary(text), "Failed to execute goal");
|
||||
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
|
||||
let missing_jar = "错误: 找不到或无法加载主类 org.gradle.wrapper.GradleWrapperMain\n";
|
||||
assert!(error_summary(missing_jar).starts_with("gradle/wrapper/gradle-wrapper.jar is missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,13 +27,6 @@ pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
imp::set_text(text, owner)
|
||||
}
|
||||
|
||||
/// The clipboard's plain text, None when it holds none. macOS only (main thread);
|
||||
/// Linux reads it asynchronously through GTK in the app, Windows through the WebView.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn text() -> Option<String> {
|
||||
imp::text()
|
||||
}
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
@@ -380,14 +373,6 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text() -> Option<String> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
// SAFETY: the type is an AppKit constant, valid for the process' lifetime.
|
||||
NSPasteboard::generalPasteboard().stringForType(unsafe { NSPasteboardTypeString }).map(|s| s.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
|
||||
@@ -67,8 +67,6 @@ pub struct EditorAppearanceSettings {
|
||||
/// Smooth caret movement curve: `snappy` | `gliding`.
|
||||
pub caret_movement_style: String,
|
||||
pub line_numbers: bool,
|
||||
/// Gutter numbering: `absolute` | `relative` | `hybrid` (absolute on the caret line, distances elsewhere).
|
||||
pub line_number_mode: String,
|
||||
pub method_separators: bool,
|
||||
/// Sub-options of `editor.showWhitespace`.
|
||||
pub whitespace_leading: bool,
|
||||
@@ -98,7 +96,6 @@ impl Default for EditorAppearanceSettings {
|
||||
smooth_caret_movement: false,
|
||||
caret_movement_style: "snappy".into(),
|
||||
line_numbers: true,
|
||||
line_number_mode: "absolute".into(),
|
||||
method_separators: false,
|
||||
whitespace_leading: true,
|
||||
whitespace_inner: true,
|
||||
|
||||
@@ -8,18 +8,13 @@
|
||||
use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// What a built-in MCP tool asks the project window for. `AgentRun` runs `command` (a shell
|
||||
/// line) in the Run tool window's agent tab `name`, on pipes, answered once the process
|
||||
/// started or failed to.
|
||||
#[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> },
|
||||
AgentRun { name: String, command: String, cwd: crate::IpcPath, env: BTreeMap<String, String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
@@ -77,9 +72,6 @@ pub enum IdeEvent {
|
||||
WorkspaceEditApplied {
|
||||
id: u32,
|
||||
paths: Vec<crate::IpcPath>,
|
||||
/// Made by an agent through the MCP server's tools: undoable like a refactoring, but
|
||||
/// not announced as one.
|
||||
by_agent: bool,
|
||||
},
|
||||
/// An open document's language changed (Settings › File Types, Override File
|
||||
/// Type), or its UI mode (a framework's root marker appeared or went).
|
||||
|
||||
@@ -21,7 +21,6 @@ pub mod process;
|
||||
pub mod project_dir;
|
||||
pub mod project_roots;
|
||||
pub mod proxy_settings;
|
||||
pub mod real_path;
|
||||
pub mod remote_settings;
|
||||
pub mod settings;
|
||||
pub mod task;
|
||||
|
||||
@@ -76,8 +76,7 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
|
||||
text
|
||||
}
|
||||
|
||||
/// The `encoding_rs` encoding of a Windows code page, when it has one.
|
||||
pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
Some(match cp {
|
||||
65001 => encoding_rs::UTF_8,
|
||||
936 => encoding_rs::GBK,
|
||||
@@ -102,56 +101,6 @@ pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding>
|
||||
})
|
||||
}
|
||||
|
||||
/// The code page Windows console programs write to a pipe in: the OEM code page (GBK on a
|
||||
/// Chinese system, Shift_JIS on a Japanese one, EUC-KR on a Korean one, 437 / 850 / 866…
|
||||
/// elsewhere). `None` when it is UTF-8 (Windows' "Use Unicode UTF-8" option) or off Windows.
|
||||
pub fn console_code_page() -> Option<u32> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// SAFETY: GetOEMCP is an argument-less query with no preconditions.
|
||||
let cp = unsafe { windows_sys::Win32::Globalization::GetOEMCP() };
|
||||
(cp != 65001).then_some(cp)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every character of code page `cp` is one byte, so text in it can be decoded
|
||||
/// piece by piece.
|
||||
#[cfg(windows)]
|
||||
pub fn is_single_byte_code_page(cp: u32) -> bool {
|
||||
use windows_sys::Win32::Globalization::{CPINFO, GetCPInfo};
|
||||
// SAFETY: CPINFO is plain data, which GetCPInfo fills in.
|
||||
unsafe {
|
||||
let mut info: CPINFO = std::mem::zeroed();
|
||||
GetCPInfo(cp, &mut info) != 0 && info.MaxCharSize == 1
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes` in code page `cp`, decoded by Windows (for the code pages `encoding_rs` lacks,
|
||||
/// like the OEM pages 437, 850 or 852).
|
||||
#[cfg(windows)]
|
||||
pub fn decode_code_page(bytes: &[u8], cp: u32) -> String {
|
||||
use windows_sys::Win32::Globalization::MultiByteToWideChar;
|
||||
let Ok(len) = i32::try_from(bytes.len()) else {
|
||||
return String::from_utf8_lossy(bytes).into_owned();
|
||||
};
|
||||
if len == 0 {
|
||||
return String::new();
|
||||
}
|
||||
// SAFETY: `bytes` holds `len` bytes; no output buffer asks for the length needed.
|
||||
let needed = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, std::ptr::null_mut(), 0) };
|
||||
if needed <= 0 {
|
||||
return String::from_utf8_lossy(bytes).into_owned();
|
||||
}
|
||||
let mut wide = vec![0u16; needed as usize];
|
||||
// SAFETY: `wide` holds `needed` units.
|
||||
let written = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, wide.as_mut_ptr(), needed) };
|
||||
String::from_utf16_lossy(&wide[..written.max(0) as usize])
|
||||
}
|
||||
|
||||
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
|
||||
const FILE_ENCODING: &str = "file.encoding";
|
||||
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
//! Where a path really lands, and whether it reaches git's own metadata, for writes that must
|
||||
//! stay inside a project and out of its `.git`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `joined` after `..`/symlink resolution as far as the file system allows:
|
||||
/// the canonical path when it exists, else the canonicalized deepest existing
|
||||
/// ancestor with the still-missing tail resolved textually — so the location
|
||||
/// of a missing path is still judged where it would really land.
|
||||
pub fn resolve_real(joined: &Path) -> PathBuf {
|
||||
let mut tail = Vec::new();
|
||||
let mut candidate = joined.to_path_buf();
|
||||
let mut real = loop {
|
||||
match dunce::canonicalize(&candidate) {
|
||||
Ok(real) => break real,
|
||||
Err(_) => {
|
||||
let Some(last) = candidate.components().next_back() else {
|
||||
return joined.to_path_buf();
|
||||
};
|
||||
tail.push(last.as_os_str().to_owned());
|
||||
if !candidate.pop() {
|
||||
return joined.to_path_buf();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for part in tail.iter().rev() {
|
||||
if part == ".." {
|
||||
real.pop();
|
||||
} else if part != "." {
|
||||
real.push(part);
|
||||
}
|
||||
}
|
||||
real
|
||||
}
|
||||
|
||||
/// Whether a path (relative to the project root) goes through git's own `.git` folder or file.
|
||||
/// git runs what it finds in `.git` (hooks, `gpg.program`, filters), so a content write must
|
||||
/// never reach it. Names are normalized for trailing dots and spaces and NTFS stream suffixes;
|
||||
/// the caller also checks the short-name-resolved path via [`resolve_real`].
|
||||
pub fn names_git_dir(rel: &Path) -> bool {
|
||||
rel.components().any(|part| {
|
||||
let name = part.as_os_str().to_string_lossy();
|
||||
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
|
||||
name.eq_ignore_ascii_case(".git")
|
||||
})
|
||||
}
|
||||
|
||||
/// A resolved write destination under `root` that reaches git's own `.git` metadata, judged by
|
||||
/// the name given and by the name the file system resolves it to.
|
||||
pub fn reaches_git_dir(root: &Path, real: &Path) -> bool {
|
||||
real.strip_prefix(root).is_ok_and(names_git_dir) || resolve_real(real).strip_prefix(root).is_ok_and(names_git_dir)
|
||||
}
|
||||
@@ -102,11 +102,6 @@ pub struct AgentSettings {
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = WhileRunning)]
|
||||
pub while_running: WhileRunning,
|
||||
/// Whether new tasks start in the agent's mode that approves everything (Claude Code's
|
||||
/// bypass permissions, Codex's full access) or in one that asks. User settings only.
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = NewTaskApprovals)]
|
||||
pub new_task_approvals: NewTaskApprovals,
|
||||
/// Settings new tasks of an agent start with: agent id → option id → value.
|
||||
#[serde(deserialize_with = "agent_defaults")]
|
||||
#[specta(type = BTreeMap<String, BTreeMap<String, String>>)]
|
||||
@@ -132,7 +127,6 @@ impl Default for AgentSettings {
|
||||
expand_tool_output: false,
|
||||
send_with: SendKey::Enter,
|
||||
while_running: WhileRunning::Queue,
|
||||
new_task_approvals: NewTaskApprovals::Bypass,
|
||||
defaults: BTreeMap::new(),
|
||||
mcp_servers: BTreeMap::new(),
|
||||
}
|
||||
@@ -276,19 +270,8 @@ pub enum WhileRunning {
|
||||
Steer,
|
||||
}
|
||||
|
||||
/// The approvals new agent tasks start with.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NewTaskApprovals {
|
||||
/// The agent's mode that approves everything without asking, when it has one.
|
||||
#[default]
|
||||
Bypass,
|
||||
/// A mode in which the agent asks before edits and commands.
|
||||
Ask,
|
||||
}
|
||||
|
||||
/// Agents › MCP Server: agents use the IDE's built-in tools.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[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.
|
||||
@@ -296,35 +279,6 @@ pub struct McpServerSettings {
|
||||
/// Run the tools that change the project or start processes without asking first
|
||||
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
|
||||
pub run_without_confirmation: bool,
|
||||
/// Finished agent tabs (`run_command`) the Run tool window shows; older ones are archived.
|
||||
pub agent_tab_limit: u32,
|
||||
/// Archived agent tabs kept; older ones are closed.
|
||||
pub agent_archive_limit: u32,
|
||||
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
|
||||
/// port forwards keep working after a restart.
|
||||
pub fixed_port: bool,
|
||||
/// The port of [`Self::fixed_port`].
|
||||
pub port: u16,
|
||||
/// Keep the bearer token across runs (in the app data, readable by this user only) rather than
|
||||
/// make a new one each run.
|
||||
pub fixed_token: bool,
|
||||
}
|
||||
|
||||
/// The default of [`McpServerSettings::port`].
|
||||
pub const DEFAULT_MCP_PORT: u16 = 26371;
|
||||
|
||||
impl Default for McpServerSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
run_without_confirmation: false,
|
||||
agent_tab_limit: 1,
|
||||
agent_archive_limit: 999,
|
||||
fixed_port: true,
|
||||
port: DEFAULT_MCP_PORT,
|
||||
fixed_token: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Absent profiles retain automatic discovery for existing projects.
|
||||
@@ -1067,28 +1021,11 @@ pub struct PythonSettings {
|
||||
pub interpreter: String,
|
||||
/// Activate the interpreter's virtualenv in new terminal sessions.
|
||||
pub activate_virtualenv: bool,
|
||||
/// Interpreters the user added to Settings › Python › All Interpreters (executable paths).
|
||||
/// Detection contributes the rest; `hidden_interpreters` removes some of them.
|
||||
pub interpreters: Vec<String>,
|
||||
/// Interpreters the user removed from that list, as executable paths: detection does
|
||||
/// not list them again.
|
||||
pub hidden_interpreters: Vec<String>,
|
||||
/// Hide interpreters that belong to another project (a virtualenv inside its folder).
|
||||
pub hide_other_project_interpreters: bool,
|
||||
/// Show each interpreter's path under its name on the interpreters page.
|
||||
pub show_interpreter_paths: bool,
|
||||
}
|
||||
|
||||
impl Default for PythonSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
interpreter: String::new(),
|
||||
activate_virtualenv: true,
|
||||
interpreters: Vec::new(),
|
||||
hidden_interpreters: Vec::new(),
|
||||
hide_other_project_interpreters: false,
|
||||
show_interpreter_paths: true,
|
||||
}
|
||||
Self { interpreter: String::new(), activate_virtualenv: true }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,13 +61,16 @@ pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option<PathBu
|
||||
|
||||
fn is_executable(path: &Path) -> bool {
|
||||
let Ok(metadata) = path.metadata() else { return false };
|
||||
if !metadata.is_file() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
|
||||
metadata.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
metadata.is_file()
|
||||
true
|
||||
}
|
||||
|
||||
/// [`search_path`]'s directories, in order and once each. Only absolute ones: a
|
||||
|
||||
@@ -37,6 +37,6 @@ zeroize = "1"
|
||||
open = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tempfile.workspace = true
|
||||
ide-ssh = { workspace = true, features = ["testing"] }
|
||||
@@ -241,12 +241,6 @@ pub enum Pool {
|
||||
Snowflake(crate::snowflake::SnowflakePool),
|
||||
}
|
||||
|
||||
/// Some servers and proxies (e.g. Aliyun RDS for MySQL 5.6) advertise `CLIENT_DEPRECATE_EOF` but
|
||||
/// still end result sets with legacy EOF packets, which sqlx then fails to read as OK packets.
|
||||
fn legacy_eof(e: &sqlx::Error) -> bool {
|
||||
matches!(e, sqlx::Error::Protocol(msg) if msg.starts_with("OK_Packet too short"))
|
||||
}
|
||||
|
||||
pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
|
||||
match &e {
|
||||
sqlx::Error::Database(d) => {
|
||||
@@ -753,7 +747,7 @@ impl Pool {
|
||||
return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle);
|
||||
}
|
||||
if source.driver == DbDriver::Snowflake {
|
||||
return crate::snowflake::SnowflakePool::connect(source, password, setup, root).await.map(Pool::Snowflake);
|
||||
return crate::snowflake::SnowflakePool::connect(source, password, setup).await.map(Pool::Snowflake);
|
||||
}
|
||||
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
|
||||
let init = Arc::new(setup.statements);
|
||||
@@ -879,16 +873,9 @@ impl Pool {
|
||||
if !tz.is_empty() {
|
||||
opts = opts.timezone(Some(tz.to_owned()));
|
||||
}
|
||||
let mut manager = MysqlManager { options: opts, init: init.clone(), timeout };
|
||||
let manager = MysqlManager { options: opts, init: init.clone(), timeout };
|
||||
// Open once directly so authentication failures retain their database error.
|
||||
let first = match manager.connect().await {
|
||||
Err(e) if legacy_eof(&e) => {
|
||||
manager.options = manager.options.clone().deprecate_eof(false);
|
||||
manager.connect().await
|
||||
}
|
||||
first => first,
|
||||
}
|
||||
.map_err(db_err)?;
|
||||
let first = manager.connect().await.map_err(db_err)?;
|
||||
let pool = Bb8Pool::builder().max_size(MAX).connection_timeout(timeout).build_unchecked(manager);
|
||||
let _ = pool.add(first);
|
||||
Ok(Pool::Mysql(ServerPool { inner: pool, closed: AtomicBool::new(false) }))
|
||||
|
||||
@@ -1491,21 +1491,18 @@ impl DbManager {
|
||||
let Some(session) = session else { return Ok(()) };
|
||||
let mut held = session.lock().await;
|
||||
let Some(mut conn) = held.take() else { return Ok(()) };
|
||||
if let Err(e) = conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await {
|
||||
// Discarding a Snowflake connection doesn't end its session, which the data source's
|
||||
// other targets share: the transaction stays open, so it stays held to be ended again.
|
||||
if matches!(conn, conn::Held::Snowflake(..)) {
|
||||
*held = Some(conn);
|
||||
} else {
|
||||
let result = async {
|
||||
conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?;
|
||||
conn.restore_default().await
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
conn.discard().await;
|
||||
Err(e)
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
if let Err(e) = conn.restore_default().await {
|
||||
conn.discard().await;
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
@@ -78,8 +78,7 @@ pub(crate) fn sso_seed(key: &str, token: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Live sessions by data source and connection settings, so the targets of one data source (its
|
||||
/// databases, the tree, consoles) opened with the same settings share one.
|
||||
/// Live sessions by their connection settings, so targets opened with the same settings share one.
|
||||
static SESSIONS: LazyLock<parking_lot::Mutex<HashMap<String, Weak<State>>>> =
|
||||
LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
|
||||
|
||||
@@ -107,15 +106,6 @@ struct Lane {
|
||||
context: parking_lot::Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
/// What [`SnowflakePool::statement`] does when the session is gone and it signs in again.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Lost<'a> {
|
||||
/// Runs the statement again on the new session, after this `USE` when there is one.
|
||||
Enter(Option<&'a str>),
|
||||
/// Fails: the statement was part of a transaction that ended with the session.
|
||||
Fail,
|
||||
}
|
||||
|
||||
const CONSOLE: usize = 0;
|
||||
const METADATA: usize = 1;
|
||||
|
||||
@@ -261,12 +251,7 @@ fn gunzip(bytes: Vec<u8>) -> IdeResult<Vec<u8>> {
|
||||
}
|
||||
|
||||
impl SnowflakePool {
|
||||
pub(crate) async fn connect(
|
||||
source: &DataSource,
|
||||
secret: Option<&str>,
|
||||
setup: Setup,
|
||||
root: Option<&std::path::Path>,
|
||||
) -> IdeResult<Self> {
|
||||
pub(crate) async fn connect(source: &DataSource, secret: Option<&str>, setup: Setup) -> IdeResult<Self> {
|
||||
let settings = source.snowflake();
|
||||
let endpoint = account::endpoint(source)?;
|
||||
let user = source.user.trim();
|
||||
@@ -317,15 +302,9 @@ impl SnowflakePool {
|
||||
lane: CONSOLE,
|
||||
};
|
||||
// Everything that makes a session what it is, the secret included (hashed: the key is only compared).
|
||||
// The data source and its project too: its databases share a session, but another data source
|
||||
// with the same settings, or the same one in another project (a plugin's source has the same id
|
||||
// in every project), gets its own: its statements and transactions are not this one's. A browser
|
||||
// sign-in is still shared through the cached ID token.
|
||||
let key = blake3::hash(
|
||||
format!(
|
||||
"{:?}\0{}\0{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
|
||||
root,
|
||||
source.id,
|
||||
"{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
|
||||
api.address(),
|
||||
settings.auth,
|
||||
secret.unwrap_or_default(),
|
||||
@@ -657,18 +636,13 @@ impl SnowflakePool {
|
||||
() = self.closed.cancelled() => return Err(closed()),
|
||||
turn = self.lane().turn.lock() => turn,
|
||||
};
|
||||
// A `USE` needs no schema to run in, and may be what makes the schema reachable. Ending a
|
||||
// transaction needs none either, and must not fail because the schema is gone meanwhile.
|
||||
let context = match first_word(sql).as_str() {
|
||||
"USE" | "COMMIT" | "ROLLBACK" => None,
|
||||
_ => {
|
||||
self.enter(schema, cancel).await?;
|
||||
self.context(schema)
|
||||
}
|
||||
};
|
||||
// A `USE` needs no schema to run in, and may be what makes the schema reachable.
|
||||
if first_word(sql) != "USE" {
|
||||
self.enter(schema, cancel).await?;
|
||||
}
|
||||
// These can move the session to another database or schema.
|
||||
let moves = matches!(first_word(sql).as_str(), "USE" | "CALL" | "EXECUTE");
|
||||
let result = self.statement(sql, skip, max_rows, Lost::Enter(context.as_deref()), cancel).await;
|
||||
let result = self.statement(sql, skip, max_rows, cancel).await;
|
||||
if moves {
|
||||
*self.lane().context.lock() = None;
|
||||
}
|
||||
@@ -681,14 +655,16 @@ impl SnowflakePool {
|
||||
&& self.lane().context.lock().as_deref() != Some(wanted.as_str())
|
||||
{
|
||||
*self.lane().context.lock() = None;
|
||||
// Refused (the session's role may not use this database or schema): the statement
|
||||
// does not run, since the session is still wherever it was, which may be another
|
||||
// target's database. Tried again with the next statement.
|
||||
if let Err(error) = self.statement(&wanted, 0, 0, Lost::Enter(None), cancel).await {
|
||||
return Err(IdeError {
|
||||
message: format!("{wanted} failed, so the statement was not run: {}", error.message),
|
||||
..error
|
||||
});
|
||||
match self.statement(&wanted, 0, 0, cancel).await {
|
||||
Ok(_) => {}
|
||||
// The session's role may not use this database or schema (after `USE ROLE`, or a
|
||||
// schema that is gone). The statement still runs, in the session's own context:
|
||||
// it may be the `USE` that leads back, or name its objects in full. Asked again
|
||||
// once a statement moves the session.
|
||||
Err(refused) if refused.kind == ErrorKind::InvalidInput => {
|
||||
tracing::warn!("Snowflake: the session did not enter its database and schema");
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
*self.lane().context.lock() = Some(wanted);
|
||||
}
|
||||
@@ -704,11 +680,11 @@ impl SnowflakePool {
|
||||
let _turn = self.lane().turn.lock().await;
|
||||
let cancel = CancellationToken::new();
|
||||
self.enter(None, &cancel).await?;
|
||||
self.statement("BEGIN", 0, 0, Lost::Enter(self.context(None).as_deref()), &cancel).await?;
|
||||
self.statement("BEGIN", 0, 0, &cancel).await?;
|
||||
let changes = async {
|
||||
let mut total = 0;
|
||||
for (sql, expect_one) in statements {
|
||||
let changed = self.statement(sql, 0, 0, Lost::Fail, &cancel).await?.affected.unwrap_or(0.0) as u64;
|
||||
let changed = self.statement(sql, 0, 0, &cancel).await?.affected.unwrap_or(0.0) as u64;
|
||||
if *expect_one && changed != 1 {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{sql}: expected to change 1 row, changed {changed}; nothing was saved"
|
||||
@@ -721,24 +697,22 @@ impl SnowflakePool {
|
||||
.await;
|
||||
match changes {
|
||||
Ok(total) => {
|
||||
self.statement("COMMIT", 0, 0, Lost::Fail, &cancel).await?;
|
||||
self.statement("COMMIT", 0, 0, &cancel).await?;
|
||||
Ok(total)
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = self.statement("ROLLBACK", 0, 0, Lost::Fail, &cancel).await;
|
||||
let _ = self.statement("ROLLBACK", 0, 0, &cancel).await;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One statement on the session; the caller holds the session's turn. `lost`: what to do when
|
||||
/// the session is gone and a new one is signed in to.
|
||||
/// One statement on the session; the caller holds the session's turn.
|
||||
async fn statement(
|
||||
&self,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
lost: Lost<'_>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let started = Instant::now();
|
||||
@@ -769,24 +743,7 @@ impl SnowflakePool {
|
||||
Ok(Outcome::Done(result)) => return Ok(*result),
|
||||
Ok(Outcome::Expired { gone }) if !renewed => {
|
||||
renewed = true;
|
||||
if !gone {
|
||||
tokens = self.renew(&tokens).await?;
|
||||
continue;
|
||||
}
|
||||
if let Lost::Fail = lost {
|
||||
// The transaction ended with the session: nothing of it was saved.
|
||||
let _ = self.login().await;
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Io,
|
||||
"Snowflake: the session was lost during the transaction; nothing was saved",
|
||||
));
|
||||
}
|
||||
// The new session is under the login's database and schema, which may be another
|
||||
// target's: back to this one's before the statement runs again.
|
||||
tokens = self.login().await?;
|
||||
if let Lost::Enter(Some(wanted)) = lost {
|
||||
self.reenter(wanted, &tokens, cancel).await?;
|
||||
}
|
||||
tokens = if gone { self.login().await? } else { self.renew(&tokens).await? };
|
||||
}
|
||||
Ok(Outcome::Expired { .. }) => {
|
||||
return Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"));
|
||||
@@ -796,33 +753,6 @@ impl SnowflakePool {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`enter`](Self::enter) on a session just signed in to (its `tokens`), within [`statement`](Self::statement).
|
||||
async fn reenter(&self, wanted: &str, tokens: &Tokens, cancel: &CancellationToken) -> IdeResult<()> {
|
||||
let request_id = uuid();
|
||||
let outcome = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
() = self.closed.cancelled() => return Err(closed()),
|
||||
outcome = self.run(wanted, 0, 0, tokens, &request_id, Instant::now()) => outcome,
|
||||
};
|
||||
match outcome {
|
||||
Ok(Outcome::Done(_)) => {
|
||||
*self.lane().context.lock() = Some(wanted.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
Ok(Outcome::Expired { .. }) => {
|
||||
Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"))
|
||||
}
|
||||
Err(error) => {
|
||||
let error = self.shared.api.scrub.clean(error);
|
||||
Err(IdeError {
|
||||
message: format!("{wanted} failed, so the statement was not run: {}", error.message),
|
||||
..error
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
&self,
|
||||
sql: &str,
|
||||
|
||||
@@ -407,64 +407,13 @@ async fn a_lost_session_signs_in_again() {
|
||||
let (listener, port) = bind().await;
|
||||
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#;
|
||||
let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
|
||||
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
.to_string();
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(status), ok(one()), ok(CLOSED_OK)]);
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(one()), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
pool.execute("select 1", 5, &CancellationToken::new()).await.unwrap();
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert!(seen[2].line().starts_with("POST /session/v1/login-request?"), "{}", seen[2].line());
|
||||
// The new session enters the target's schema before the statement runs again.
|
||||
assert_eq!(seen[3].json()["sqlText"], "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"");
|
||||
assert_eq!(seen[4].json()["sqlText"], "select 1");
|
||||
assert_eq!(seen[4].header("authorization"), Some("Snowflake Token=\"session-token-3\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_lost_session_runs_the_statement_in_its_own_database_again() {
|
||||
let (listener, port) = bind().await;
|
||||
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#;
|
||||
let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
|
||||
let status = || {
|
||||
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
.to_string()
|
||||
};
|
||||
// Raw Data entered, the statement finds the session gone, a new login (under ANALYTICS),
|
||||
// Raw Data entered again, the statement.
|
||||
let replies = vec![ok(LOGIN_OK), ok(status()), ok(gone), ok(again), ok(status()), ok(one()), ok(CLOSED_OK)];
|
||||
let server = serve(listener, replies);
|
||||
let first = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let mut other = source(port);
|
||||
other.database = "Raw Data".into();
|
||||
other.options.default_schema = String::new();
|
||||
let second = Pool::connect(&other, None, None).await.unwrap();
|
||||
second.execute("delete from t", 1, &CancellationToken::new()).await.unwrap();
|
||||
first.close().await;
|
||||
second.close().await;
|
||||
let sent: Vec<String> =
|
||||
server.await.unwrap()[1..6].iter().map(|r| r.json()["sqlText"].as_str().unwrap_or("").to_owned()).collect();
|
||||
let raw = "USE DATABASE \"Raw Data\"";
|
||||
assert_eq!(sent, [raw, "delete from t", "", raw, "delete from t"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_transaction_ends_even_when_its_schema_is_gone() {
|
||||
let (listener, port) = bind().await;
|
||||
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
.to_string();
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(status), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
// The console's schema was dropped since it was picked: ending the transaction doesn't switch to it.
|
||||
let mut console = crate::conn::Held::acquire(&pool).await.unwrap();
|
||||
console.apply_context(Some("Dropped")).await.unwrap();
|
||||
console.execute(&pool, "ROLLBACK", 0, &CancellationToken::new()).await.unwrap();
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert_eq!(seen[1].json()["sqlText"], "ROLLBACK");
|
||||
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-3\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -765,7 +714,7 @@ async fn a_console_schema_is_switched_with_its_statement_and_only_when_needed()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_role_that_may_not_use_the_schema_runs_nothing_elsewhere() {
|
||||
async fn a_role_that_may_not_use_the_schema_still_runs_statements() {
|
||||
let status = || {
|
||||
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
@@ -773,12 +722,14 @@ async fn a_role_that_may_not_use_the_schema_runs_nothing_elsewhere() {
|
||||
};
|
||||
let refused = r#"{"success":false,"code":"002043","message":"SQL compilation error:\nObject does not exist, or operation cannot be performed."}"#;
|
||||
let (listener, port) = bind().await;
|
||||
// use role | the schema is refused, the statement is not sent | asked again, refused again |
|
||||
// use role back, the schema is entered, the statement runs
|
||||
// use role | the schema is refused once, the statement runs | the next one does not ask again |
|
||||
// a role that is not granted | use role back, the schema is entered again
|
||||
let replies = vec![
|
||||
ok(LOGIN_OK),
|
||||
ok(status()),
|
||||
ok(refused),
|
||||
ok(one()),
|
||||
ok(one()),
|
||||
ok(refused),
|
||||
ok(status()),
|
||||
ok(status()),
|
||||
@@ -789,22 +740,20 @@ async fn a_role_that_may_not_use_the_schema_runs_nothing_elsewhere() {
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
pool.execute("use role auditor", 0, &cancel).await.unwrap();
|
||||
let error = pool.execute("delete from t", 1, &cancel).await.unwrap_err();
|
||||
assert!(
|
||||
error.message.contains("USE SCHEMA")
|
||||
&& error.message.contains("was not run")
|
||||
&& error.message.contains("002043"),
|
||||
"{}",
|
||||
error.message
|
||||
);
|
||||
assert!(pool.execute("select 2", 1, &cancel).await.is_err());
|
||||
pool.execute("select 1", 1, &cancel).await.unwrap();
|
||||
pool.execute("select 2", 1, &cancel).await.unwrap();
|
||||
let missing = pool.execute("use role nobody", 0, &cancel).await.unwrap_err();
|
||||
assert!(missing.message.contains("002043") && missing.message.contains("granted"), "{}", missing.message);
|
||||
pool.execute("use role analyst", 0, &cancel).await.unwrap();
|
||||
pool.execute("select 3", 1, &cancel).await.unwrap();
|
||||
pool.close().await;
|
||||
let sent: Vec<String> =
|
||||
server.await.unwrap()[1..7].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
|
||||
server.await.unwrap()[1..9].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
|
||||
let schema = "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"";
|
||||
assert_eq!(sent, ["use role auditor", schema, schema, "use role analyst", schema, "select 3"]);
|
||||
assert_eq!(
|
||||
sent,
|
||||
["use role auditor", schema, "select 1", "select 2", "use role nobody", "use role analyst", schema, "select 3"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -836,45 +785,6 @@ async fn databases_of_one_data_source_share_a_login() {
|
||||
assert!(seen[4].line().starts_with("POST /session?delete=true"), "{}", seen[4].line());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_sources_with_the_same_settings_have_their_own_sessions() {
|
||||
let (listener, port) = bind().await;
|
||||
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(second), ok(one()), ok(one()), ok(CLOSED_OK), ok(CLOSED_OK)]);
|
||||
let first = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let mut copy = source(port);
|
||||
copy.id = format!("{}-copy", copy.id);
|
||||
let other = Pool::connect(©, None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
first.execute("select 1", 1, &cancel).await.unwrap();
|
||||
other.execute("select 2", 1, &cancel).await.unwrap();
|
||||
first.close().await;
|
||||
other.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert!(seen[1].line().starts_with("POST /session/v1/login-request?"), "{}", seen[1].line());
|
||||
assert_eq!(seen[2].header("authorization"), Some("Snowflake Token=\"session-token-1\""));
|
||||
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_data_source_in_two_projects_has_a_session_in_each() {
|
||||
let (listener, port) = bind().await;
|
||||
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(second), ok(one()), ok(one()), ok(CLOSED_OK), ok(CLOSED_OK)]);
|
||||
// A plugin's data source has the same id in every project.
|
||||
let first = Pool::connect(&source(port), None, Some(std::path::Path::new("/projects/a"))).await.unwrap();
|
||||
let other = Pool::connect(&source(port), None, Some(std::path::Path::new("/projects/b"))).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
first.execute("select 1", 1, &cancel).await.unwrap();
|
||||
other.execute("select 2", 1, &cancel).await.unwrap();
|
||||
first.close().await;
|
||||
other.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert!(seen[1].line().starts_with("POST /session/v1/login-request?"), "{}", seen[1].line());
|
||||
assert_eq!(seen[2].header("authorization"), Some("Snowflake Token=\"session-token-1\""));
|
||||
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_runs_on_its_own_session_beside_a_busy_console() {
|
||||
let (listener, port) = bind().await;
|
||||
|
||||
@@ -143,12 +143,9 @@ mod tests {
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let (count, on_change) = counting();
|
||||
let _watch = SourcesWatch::start(&root, None, on_change).unwrap();
|
||||
// FSEvents can deliver the creation of `.openidea/` above after the watch started.
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
let seen = count.load(Ordering::SeqCst);
|
||||
std::fs::write(root.join(".openidea/settings.toml"), "").unwrap();
|
||||
std::fs::write(root.join("main.py"), "").unwrap();
|
||||
std::thread::sleep(SETTLE * 3);
|
||||
assert_eq!(count.load(Ordering::SeqCst), seen);
|
||||
assert_eq!(count.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
@@ -183,51 +183,3 @@ async fn a_reused_physical_connection_restores_the_configured_database() -> IdeR
|
||||
assert_eq!(pool.execute("SELECT CONNECTION_ID()", 1, &c).await?.rows, backend);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forwards to the test server, adding `CLIENT_DEPRECATE_EOF` to the capabilities its greeting
|
||||
/// advertises, like proxies that set the flag but leave a 5.x server sending legacy EOF packets.
|
||||
async fn deprecate_eof_proxy(host: String, port: u16) -> u16 {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let local = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut client, _)) = listener.accept().await {
|
||||
let mut server = tokio::net::TcpStream::connect((host.as_str(), port)).await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let mut header = [0u8; 4];
|
||||
server.read_exact(&mut header).await.unwrap();
|
||||
let len = u32::from_le_bytes([header[0], header[1], header[2], 0]) as usize;
|
||||
let mut greeting = vec![0u8; len];
|
||||
server.read_exact(&mut greeting).await.unwrap();
|
||||
// protocol version, NUL-terminated server version, connection id, 8 bytes of
|
||||
// auth data, filler, lower capabilities, charset, status, upper capabilities.
|
||||
let version_end = 1 + greeting[1..].iter().position(|&b| b == 0).unwrap();
|
||||
let upper = version_end + 1 + 4 + 8 + 1 + 2 + 1 + 2;
|
||||
greeting[upper + 1] |= 0x01; // bit 24 of the capability flags
|
||||
client.write_all(&header).await.unwrap();
|
||||
client.write_all(&greeting).await.unwrap();
|
||||
let _ = tokio::io::copy_bidirectional(&mut client, &mut server).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
local
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn servers_that_advertise_deprecate_eof_but_send_legacy_eof_packets() -> IdeResult<()> {
|
||||
use ide_db::Pool;
|
||||
let Some((host, port, user, password)) = server() else { return Ok(()) };
|
||||
let source = DataSource {
|
||||
driver: DbDriver::Mysql,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(deprecate_eof_proxy(host, port).await),
|
||||
user,
|
||||
..Default::default()
|
||||
};
|
||||
let pool = Pool::connect(&source, Some(&password), None).await?;
|
||||
assert_eq!(pool.rows("SELECT 1 + 1", 1).await?, vec![vec![Some("2".into())]]);
|
||||
let c = CancellationToken::new();
|
||||
let r = pool.execute("SELECT 'a' UNION ALL SELECT 'b'", 10, &c).await?;
|
||||
assert_eq!(r.rows, vec![vec![Some("a".into())], vec![Some("b".into())]]);
|
||||
Ok(())
|
||||
}
|
||||
@@ -458,13 +458,16 @@ fn canonical_dir(p: &Path) -> PathBuf {
|
||||
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
let Ok(meta) = std::fs::metadata(p) else { return false };
|
||||
if !meta.is_file() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
meta.is_file() && meta.permissions().mode() & 0o111 != 0
|
||||
meta.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
meta.is_file()
|
||||
true
|
||||
}
|
||||
|
||||
/// blake3 of a file, hex.
|
||||
|
||||
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.7.0";
|
||||
pub const ENGINE_VERSION: &str = "1.6.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
//! Values coding agents are configured with that may be secrets (MCP servers' environment
|
||||
//! variables and HTTP headers, agents' environment), kept with the saved passwords in the OS
|
||||
//! credential store; the files that configure them keep only the names. Without a credential
|
||||
//! store (a headless Linux host) a value is kept in an owner-only file of the user's data
|
||||
//! instead, never in a project's files, and the user is told.
|
||||
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::{IdeEvent, IdeResult, SharedSink};
|
||||
use ide_ssh::secrets::SecretFile;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct AgentSecrets {
|
||||
store: SecretFile,
|
||||
/// Values the credential store refused, by key.
|
||||
local: PathBuf,
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
/// Where [`AgentSecrets::set`] kept a value.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kept {
|
||||
Store,
|
||||
LocalFile,
|
||||
}
|
||||
|
||||
impl AgentSecrets {
|
||||
pub fn new(data_dir: &Path) -> Self {
|
||||
Self::with_store(
|
||||
SecretFile::new(data_dir.join("agent-secrets.json")),
|
||||
data_dir.join("agent-secrets.local.json"),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_store(store: SecretFile, local: PathBuf) -> Self {
|
||||
Self { store, local, lock: Mutex::new(()) }
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<String> {
|
||||
self.store.get(key).or_else(|| self.read_local().remove(key))
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, value: &str) -> IdeResult<Kept> {
|
||||
match self.store.set(key, value) {
|
||||
Ok(()) => {
|
||||
self.edit_local(|local| local.remove(key).is_some())?;
|
||||
Ok(Kept::Store)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("agent settings: {key} is kept in {}: {}", self.local.display(), e.message);
|
||||
self.edit_local(|local| local.insert(key.to_owned(), value.to_owned()).as_deref() != Some(value))?;
|
||||
Ok(Kept::LocalFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) {
|
||||
self.store.delete(key);
|
||||
if let Err(e) = self.edit_local(|local| local.remove(key).is_some()) {
|
||||
tracing::warn!("agent settings: could not forget {key}: {}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys that start with `prefix`, sorted.
|
||||
pub fn keys(&self, prefix: &str) -> Vec<String> {
|
||||
let keys: BTreeSet<String> = self.store.keys().into_iter().chain(self.read_local().into_keys()).collect();
|
||||
keys.into_iter().filter(|k| k.starts_with(prefix)).collect()
|
||||
}
|
||||
|
||||
/// Tells the window that values went to the owner-only file rather than the credential store.
|
||||
pub fn notify_kept_locally(&self, sink: &SharedSink) {
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Warning,
|
||||
title: "No credential store".into(),
|
||||
body: Some(format!(
|
||||
"Values of agent and MCP server settings are kept in {}, readable only by you.",
|
||||
self.local.display()
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
fn read_local(&self) -> BTreeMap<String, String> {
|
||||
match std::fs::read(&self.local) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("agent settings: {}: {e}", self.local.display());
|
||||
BTreeMap::new()
|
||||
}),
|
||||
Err(_) => BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_local(&self, edit: impl FnOnce(&mut BTreeMap<String, String>) -> bool) -> IdeResult<()> {
|
||||
let _g = self.lock.lock();
|
||||
let mut local = self.read_local();
|
||||
if !edit(&mut local) {
|
||||
return Ok(());
|
||||
}
|
||||
if local.is_empty() {
|
||||
return match std::fs::remove_file(&self.local) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
|
||||
_ => Ok(()),
|
||||
};
|
||||
}
|
||||
if let Some(dir) = self.local.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&local).map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
|
||||
ide_vfs::write_atomic_private(&self.local, &bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_ssh::secrets::testing::{files_containing, use_memory_store};
|
||||
use ide_ssh::secrets::{StoreError, StoreResult, Vault};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A machine without a credential store (a headless Linux host).
|
||||
struct NoStore;
|
||||
|
||||
impl Vault for NoStore {
|
||||
fn available(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn get(&self, _: &str, _: &str) -> StoreResult<String> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
fn set(&self, _: &str, _: &str, _: &str) -> StoreResult<()> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
fn delete(&self, _: &str, _: &str) -> StoreResult<()> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_go_to_the_credential_store() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let secrets = AgentSecrets::new(tmp.path());
|
||||
assert_eq!(secrets.set("mcp/user/a/env/TOKEN", "value-7c1e").unwrap(), Kept::Store);
|
||||
assert_eq!(files_containing(tmp.path(), "value-7c1e"), Vec::<PathBuf>::new());
|
||||
assert_eq!(AgentSecrets::new(tmp.path()).get("mcp/user/a/env/TOKEN").as_deref(), Some("value-7c1e"));
|
||||
assert_eq!(secrets.keys("mcp/user/"), ["mcp/user/a/env/TOKEN"]);
|
||||
secrets.delete("mcp/user/a/env/TOKEN");
|
||||
assert_eq!(secrets.get("mcp/user/a/env/TOKEN"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_credential_store_values_stay_in_an_owner_only_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let local = tmp.path().join("agent-secrets.local.json");
|
||||
let store = SecretFile::with_vault(
|
||||
tmp.path().join("agent-secrets.json"),
|
||||
|v| serde_json::from_value(v).ok(),
|
||||
Arc::new(NoStore),
|
||||
);
|
||||
let secrets = AgentSecrets::with_store(store, local.clone());
|
||||
assert_eq!(secrets.set("mcp/user/a/env/TOKEN", "value-7c1e").unwrap(), Kept::LocalFile);
|
||||
assert_eq!(files_containing(tmp.path(), "value-7c1e"), std::slice::from_ref(&local));
|
||||
assert_eq!(secrets.get("mcp/user/a/env/TOKEN").as_deref(), Some("value-7c1e"));
|
||||
assert_eq!(secrets.keys("mcp/"), ["mcp/user/a/env/TOKEN"]);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(std::fs::metadata(&local).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
secrets.delete("mcp/user/a/env/TOKEN");
|
||||
assert_eq!(secrets.get("mcp/user/a/env/TOKEN"), None);
|
||||
assert!(!local.exists());
|
||||
}
|
||||
}
|
||||
@@ -426,9 +426,9 @@ fn plugins_dir() -> Result<PathBuf, String> {
|
||||
if let Some(dir) = std::env::var_os("OPENIDEA_PLUGINS_DIR") {
|
||||
return Ok(PathBuf::from(dir));
|
||||
}
|
||||
ide_host::data_dirs::data_dir("ai.l0ng.openidea")
|
||||
.map(|d| d.join("plugins"))
|
||||
.map_err(|error| format!("{error}; set OPENIDEA_PLUGINS_DIR"))
|
||||
dirs::data_dir()
|
||||
.map(|d| d.join("ai.l0ng.openidea").join("plugins"))
|
||||
.ok_or_else(|| "cannot find the app data directory; set OPENIDEA_PLUGINS_DIR".into())
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
|
||||
@@ -5,52 +5,9 @@ use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Moves the rebuildable local data (tools, language servers, indexes, caches).
|
||||
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
|
||||
/// configuration and roaming data keep following the OS.
|
||||
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
|
||||
/// Moves the configuration (settings, recent projects, language definitions, window state).
|
||||
pub const CONFIG_ENV: &str = "OPENIDEA_CONFIG_DIR";
|
||||
/// Moves the user data (plugins, scratches, Local History, trusted projects).
|
||||
pub const DATA_ENV: &str = "OPENIDEA_DATA_DIR";
|
||||
/// Moves the logs.
|
||||
pub const LOG_ENV: &str = "OPENIDEA_LOG_DIR";
|
||||
|
||||
/// `$OPENIDEA_CONFIG_DIR/<identifier>` when set, else the OS configuration directory
|
||||
/// (Tauri's `app_config_dir`). Like the local data, the identifier keeps release and
|
||||
/// per-worktree debug builds apart under one root; a relative path is an error.
|
||||
pub fn config_dir(identifier: &str) -> Result<PathBuf, String> {
|
||||
resolve_env(CONFIG_ENV, std::env::var_os(CONFIG_ENV), dirs::config_dir(), identifier)
|
||||
}
|
||||
|
||||
/// `$OPENIDEA_DATA_DIR/<identifier>` when set, else the OS data directory (Tauri's `app_data_dir`).
|
||||
pub fn data_dir(identifier: &str) -> Result<PathBuf, String> {
|
||||
resolve_env(DATA_ENV, std::env::var_os(DATA_ENV), dirs::data_dir(), identifier)
|
||||
}
|
||||
|
||||
/// `$OPENIDEA_LOG_DIR/<identifier>` when set, else `~/Library/Logs/<identifier>` on macOS and
|
||||
/// `<local data>/<identifier>/logs` elsewhere (Tauri's `app_log_dir`).
|
||||
pub fn log_dir(identifier: &str) -> Result<PathBuf, String> {
|
||||
let os = if cfg!(target_os = "macos") {
|
||||
dirs::home_dir().map(|home| home.join("Library/Logs").join(identifier))
|
||||
} else {
|
||||
dirs::data_local_dir().map(|dir| dir.join(identifier).join("logs"))
|
||||
};
|
||||
match std::env::var_os(LOG_ENV).filter(|v| !v.is_empty()) {
|
||||
Some(var) => resolve_env(LOG_ENV, Some(var), None, identifier),
|
||||
None => os.ok_or_else(|| "cannot find the log directory".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory under `$<env>` when set (absolute, not created here), else under the OS directory.
|
||||
fn resolve_env(env: &str, var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
|
||||
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
|
||||
return os.map(|dir| dir.join(identifier)).ok_or_else(|| format!("cannot find the directory {env} replaces"));
|
||||
};
|
||||
// The working directory depends on how the IDE was launched.
|
||||
if !root.is_absolute() {
|
||||
return Err(format!("{env} must be an absolute path: {}", root.display()));
|
||||
}
|
||||
Ok(root.join(identifier))
|
||||
}
|
||||
|
||||
/// A custom local-data root chosen in Settings › System Settings › Caches,
|
||||
/// stored in the configuration directory. The environment variable still wins.
|
||||
@@ -94,11 +51,11 @@ fn startup_resolve(
|
||||
/// The file holding the custom local-data root, if the platform exposes its
|
||||
/// configuration directory. Both startup and Settings use this one path.
|
||||
pub fn override_path(identifier: &str) -> Option<PathBuf> {
|
||||
config_dir(identifier).ok().map(|dir| dir.join(OVERRIDE_FILE))
|
||||
dirs::config_dir().map(|dir| dir.join(identifier).join(OVERRIDE_FILE))
|
||||
}
|
||||
|
||||
fn migration_path(identifier: &str) -> Option<PathBuf> {
|
||||
config_dir(identifier).ok().map(|dir| dir.join(MIGRATION_FILE))
|
||||
dirs::config_dir().map(|dir| dir.join(identifier).join(MIGRATION_FILE))
|
||||
}
|
||||
|
||||
/// The custom root stored by Settings, if any (validated when it is used).
|
||||
@@ -536,25 +493,6 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_data_and_log_dirs_follow_the_os_unless_their_variable_is_set() {
|
||||
let os = Some(PathBuf::from("/os"));
|
||||
for env in [CONFIG_ENV, DATA_ENV, LOG_ENV] {
|
||||
for var in [None, Some(OsString::new())] {
|
||||
assert_eq!(resolve_env(env, var, os.clone(), "id").unwrap(), PathBuf::from("/os/id"));
|
||||
}
|
||||
let root = std::env::temp_dir().join("openidea-moved");
|
||||
assert_eq!(resolve_env(env, Some(root.clone().into()), os.clone(), "id").unwrap(), root.join("id"));
|
||||
// Release and per-worktree debug builds stay apart under one root.
|
||||
assert_ne!(
|
||||
resolve_env(env, Some(root.clone().into()), None, "dev-a").unwrap(),
|
||||
resolve_env(env, Some(root.clone().into()), None, "dev-b").unwrap()
|
||||
);
|
||||
let error = resolve_env(env, Some("relative/dir".into()), os.clone(), "id").unwrap_err();
|
||||
assert!(error.contains(env) && error.contains("absolute"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_dir_follows_the_os_unless_the_variable_is_set() {
|
||||
let os = Some(PathBuf::from("/os-local"));
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! wraps one [`Host`] and adds windows and menus; `openidea-server`
|
||||
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
|
||||
|
||||
pub mod agent_secrets;
|
||||
pub mod data_dirs;
|
||||
pub mod environment;
|
||||
mod formatter;
|
||||
@@ -60,10 +59,7 @@ pub struct Host {
|
||||
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
|
||||
pub mcp_reports: mcp::Reports,
|
||||
pub mcp_requests: mcp::Requests,
|
||||
/// Commands agents run in the Run tool window (`run_command`).
|
||||
pub mcp_agent_runs: mcp::AgentRuns,
|
||||
pub trust: trust::TrustStore,
|
||||
pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
|
||||
pub config_dir: PathBuf,
|
||||
/// Persistent user state, including credentials and recovery history.
|
||||
pub data_dir: PathBuf,
|
||||
@@ -95,8 +91,6 @@ pub struct Host {
|
||||
proxy_password: ide_ssh::secrets::SecretFile,
|
||||
/// The proxy password when it could not be saved (no credential store).
|
||||
proxy_session_password: Mutex<Option<String>>,
|
||||
/// Values of agents' and MCP servers' environment variables and headers (see `agent_secrets.rs`).
|
||||
pub agent_secrets: Arc<agent_secrets::AgentSecrets>,
|
||||
pub(crate) sink_for: SinkFactory,
|
||||
owner: fn(&str) -> &str,
|
||||
pub runtime: tokio::runtime::Handle,
|
||||
@@ -143,12 +137,9 @@ impl Host {
|
||||
mcp_executable: RwLock::default(),
|
||||
mcp_reports: Default::default(),
|
||||
mcp_requests: Default::default(),
|
||||
mcp_agent_runs: Default::default(),
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
mcp_approvals: Arc::new(trust::McpServerApprovals::load(&data_dir)),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
proxy_session_password: Mutex::default(),
|
||||
agent_secrets: Arc::new(agent_secrets::AgentSecrets::new(&data_dir)),
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
|
||||
@@ -272,7 +263,6 @@ impl Host {
|
||||
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
|
||||
self.mcp_reports.forget_window(window);
|
||||
self.mcp_requests.forget_window(window);
|
||||
self.mcp_agent_runs.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);
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
//! `check_files`: fresh diagnostics from the language servers right after an edit, and why a
|
||||
//! file could not be checked (no server for it, not installed, crashed, misconfigured…).
|
||||
|
||||
use super::editors::problem;
|
||||
use crate::mcp::run::timeout;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{IdeError, IdeResult, Severity};
|
||||
use ide_lsp::DocumentCheck;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 30_000;
|
||||
const MAX_FILES: usize = 20;
|
||||
|
||||
pub fn check_files() -> Tool {
|
||||
Tool::new(
|
||||
"check_files",
|
||||
&format!(
|
||||
"Checks files with the language servers now and returns their problems: call it after changing files \
|
||||
(with any tool, inside or outside the IDE). Each file is synced from disk and saved to its servers, so checks that \
|
||||
run on save (rust-analyzer's cargo check) run too; then the call waits until every server answered for the current \
|
||||
text and finished the work it started, up to `timeout` milliseconds (default {DEFAULT_TIMEOUT_MS}) for all files. \
|
||||
Unlike get_file_problems, this is a fresh analysis, before the inspection profile's filtering.
|
||||
|
||||
Per file, `status` says what the problems are worth:
|
||||
- checked: every server answered; `problems` is complete (empty means no problems).
|
||||
- incomplete: some server was still analysing when the wait ended, or could not check; call again or raise `timeout`.
|
||||
- serverUnavailable: no server could check. `servers` gives each one's `status` (missing: not installed; \
|
||||
misconfigured; crashed; starting: did not finish starting) and `message`; tell the user, since the IDE has to be \
|
||||
fixed (Settings › Languages & Frameworks › Language Servers), and check another way meanwhile (a build or a compiler).
|
||||
- noLanguageServer: no language server is configured for this kind of file.
|
||||
- untrusted: language servers don't run in an untrusted project.
|
||||
- failed: the file could not be opened (`error`).
|
||||
`unsavedInEditor` is true when the file has unsaved changes in the IDE: the problems are for the editor's text, not \
|
||||
the file on disk. Lines and UTF-16 columns are 1-based."
|
||||
),
|
||||
|call| async move {
|
||||
let raw = call.str_list("filePaths")?;
|
||||
if raw.is_empty() {
|
||||
return Err(IdeError::invalid("filePaths must name at least one file"));
|
||||
}
|
||||
if raw.len() > MAX_FILES {
|
||||
return Err(IdeError::invalid(format!("at most {MAX_FILES} files at a time")));
|
||||
}
|
||||
let errors_only = call.flag("errorsOnly", true)?;
|
||||
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
|
||||
let mut paths = Vec::new();
|
||||
for p in raw {
|
||||
paths.push(call.resolve_readable(&p).await?);
|
||||
}
|
||||
let checks = paths.into_iter().map(|path| check_one(&call, path, limit, errors_only));
|
||||
let files = futures::future::join_all(checks).await;
|
||||
Output::json(json!({ "files": files }))
|
||||
},
|
||||
)
|
||||
.param(Param::required("filePaths", ParamType::StringList, "Files to check, relative to the project root"))
|
||||
.param(Param::optional(
|
||||
"errorsOnly",
|
||||
ParamType::Boolean,
|
||||
"Whether to return only errors (default) or warnings and hints too",
|
||||
))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the servers at most"))
|
||||
}
|
||||
|
||||
async fn check_one(call: &Call, path: PathBuf, limit: Duration, errors_only: bool) -> Value {
|
||||
let mut out = json!({ "filePath": call.display(&path) });
|
||||
if !call.project().is_trusted() {
|
||||
out["status"] = "untrusted".into();
|
||||
return out;
|
||||
}
|
||||
match check(call, &path, limit).await {
|
||||
Ok((check, dirty, text)) => {
|
||||
let servers: Vec<Value> = check
|
||||
.servers
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let mut v = json!({ "name": s.name, "status": s.status, "checked": s.checked });
|
||||
if let Some(m) = &s.message {
|
||||
v["message"] = m.clone().into();
|
||||
}
|
||||
if s.incomplete {
|
||||
v["incomplete"] = true.into();
|
||||
}
|
||||
if let Some(e) = &s.error {
|
||||
v["error"] = e.clone().into();
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
out["status"] = file_status(&check).into();
|
||||
out["problems"] = check
|
||||
.diagnostics
|
||||
.into_iter()
|
||||
.filter(|d| !errors_only || d.severity == Severity::Error)
|
||||
.map(|d| problem(d, &text))
|
||||
.collect::<Vec<_>>()
|
||||
.into();
|
||||
out["servers"] = servers.into();
|
||||
if dirty {
|
||||
out["unsavedInEditor"] = true.into();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
out["status"] = "failed".into();
|
||||
out["error"] = e.message.into();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn file_status(check: &DocumentCheck) -> &'static str {
|
||||
if check.servers.is_empty() {
|
||||
"noLanguageServer"
|
||||
} else if !check.servers.iter().any(|s| s.checked) {
|
||||
"serverUnavailable"
|
||||
} else if check.servers.iter().all(|s| s.checked && !s.incomplete) {
|
||||
"checked"
|
||||
} else {
|
||||
"incomplete"
|
||||
}
|
||||
}
|
||||
|
||||
/// The check of `path`, whether it has unsaved changes in the IDE, and its text.
|
||||
async fn check(call: &Call, path: &std::path::Path, limit: Duration) -> IdeResult<(DocumentCheck, bool, String)> {
|
||||
let project = call.project().clone();
|
||||
let docs = project.documents.clone();
|
||||
let p = path.to_owned();
|
||||
let at = Instant::now();
|
||||
// Synced now: the file watcher's reload of a change made outside the IDE may not have run.
|
||||
let (doc, changed, dirty) = call
|
||||
.host()
|
||||
.blocking(move || {
|
||||
let before = docs.by_path(&p).and_then(|d| docs.version(d).ok().map(|v| (d, v)));
|
||||
docs.on_disk_changed(std::slice::from_ref(&p));
|
||||
let doc = docs.open(&p)?.id;
|
||||
let changed = before.is_none_or(|(d, v)| d != doc || docs.version(d).is_ok_and(|now| now != v));
|
||||
Ok((doc, changed, docs.is_dirty(doc).unwrap_or(false)))
|
||||
})
|
||||
.await?;
|
||||
let result = project.lsp.check_document(doc, changed.then_some(at), limit, &call.cancel).await;
|
||||
let text = project.documents.text_snapshot(doc).map(|t| t.text).unwrap_or_default();
|
||||
let docs = project.documents.clone();
|
||||
let _ = call
|
||||
.host()
|
||||
.blocking(move || {
|
||||
docs.close(doc);
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
Ok((result?, dirty, text))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::event::ServerStatus;
|
||||
use ide_lsp::CheckedServer;
|
||||
|
||||
fn server(status: ServerStatus, checked: bool, incomplete: bool) -> CheckedServer {
|
||||
CheckedServer { name: "s".into(), status, message: None, checked, incomplete, error: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statuses_say_whether_the_problems_are_complete() {
|
||||
let check = |servers| file_status(&DocumentCheck { diagnostics: Vec::new(), servers });
|
||||
assert_eq!(check(vec![]), "noLanguageServer");
|
||||
assert_eq!(check(vec![server(ServerStatus::Missing, false, false)]), "serverUnavailable");
|
||||
assert_eq!(check(vec![server(ServerStatus::Ready, true, false)]), "checked");
|
||||
assert_eq!(check(vec![server(ServerStatus::Busy, true, true)]), "incomplete");
|
||||
assert_eq!(
|
||||
check(vec![server(ServerStatus::Ready, true, false), server(ServerStatus::Crashed, false, false)]),
|
||||
"incomplete"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ async fn create_or_replace(call: &Call, path: PathBuf, text: Option<String>, ove
|
||||
return Ok(());
|
||||
}
|
||||
let edit = crate::ops::lsp::prepare_workspace_edit(&call.host, call.window(), edit).await?;
|
||||
crate::ops::lsp::apply_agent_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).
|
||||
@@ -112,17 +112,16 @@ fn whole_range(text: &str) -> Range {
|
||||
/// 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 {
|
||||
a == b || matches!((dunce::canonicalize(a), dunce::canonicalize(b)), (Ok(x), Ok(y)) if x == y) || same_inode(a, b)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn same_inode(a: &Path, b: &Path) -> bool {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
matches!((std::fs::metadata(a), std::fs::metadata(b)), (Ok(x), Ok(y)) if x.dev() == y.dev() && x.ino() == y.ino())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn same_inode(_: &Path, _: &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
|
||||
}
|
||||
|
||||
@@ -317,7 +316,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
|
||||
preconditions: Vec::new(),
|
||||
};
|
||||
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
|
||||
crate::ops::lsp::apply_agent_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.
|
||||
@@ -400,7 +399,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
|
||||
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_agent_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"));
|
||||
}
|
||||
|
||||
@@ -385,7 +385,7 @@ targetIndex, or narrow the call with a contextSnippet that shows the one you mea
|
||||
_ => None,
|
||||
});
|
||||
let (h, w) = (host.clone(), window.to_owned());
|
||||
let written = crate::ops::lsp::apply_agent_workspace_edit(&h, &w, edit).await;
|
||||
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));
|
||||
|
||||
@@ -65,7 +65,7 @@ async fn current_file(call: Call) -> IdeResult<Output> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn problem(d: Diagnostic, text: &str) -> Value {
|
||||
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(),
|
||||
|
||||
@@ -30,14 +30,9 @@ fn read_file() -> Tool {
|
||||
"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; a longer file keeps its first and last
|
||||
lines with a `…N lines truncated…` marker between them.
|
||||
With `offset` or `limit`, returns exactly lines `offset` to `offset + limit - 1` (fewer at the end of the file),
|
||||
followed by `…N more lines; read on with offset=M…` when the file goes on.
|
||||
By default, reads up to 2000 lines starting from the beginning of the file.
|
||||
The maximum accepted limit is 5000 lines.",
|
||||
|call| async move {
|
||||
// An explicit range is read as asked; the default read of a whole file keeps its head and tail.
|
||||
let window = call.opt_i64("offset")?.is_some() || call.opt_i64("limit")?.is_some();
|
||||
let offset = positive(&call, "offset", 1)?;
|
||||
let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?;
|
||||
if limit > MAX_READ_LIMIT {
|
||||
@@ -55,7 +50,7 @@ The maximum accepted limit is 5000 lines.",
|
||||
)));
|
||||
}
|
||||
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, window)?))
|
||||
Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?))
|
||||
},
|
||||
)
|
||||
.param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION))
|
||||
@@ -75,12 +70,10 @@ fn positive(call: &Call, name: &str, default: i64) -> IdeResult<i64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines `start..` of `text` as `L<n>: <line>`, at most `max_lines` of them. A `window` is
|
||||
/// exactly the lines from `start` on, then how many lines remain; otherwise, when more than
|
||||
/// `max_lines` remain, the first and the last halves of the budget with a `…N lines truncated…`
|
||||
/// marker between them.
|
||||
/// 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, window: bool) -> IdeResult<String> {
|
||||
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 {
|
||||
@@ -93,15 +86,6 @@ fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> Ide
|
||||
if available <= max_lines {
|
||||
return Ok(range(start, count));
|
||||
}
|
||||
if window {
|
||||
let end = start + max_lines - 1;
|
||||
return Ok(format!(
|
||||
"{}\n\u{2026}{} more lines; read on with offset={}\u{2026}",
|
||||
range(start, end),
|
||||
count - end,
|
||||
end + 1
|
||||
));
|
||||
}
|
||||
let head = max_lines / 2;
|
||||
let tail = max_lines - head;
|
||||
let head_end = start + head - 1;
|
||||
@@ -261,7 +245,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn slice(text: &str, start: usize, max: usize) -> String {
|
||||
render_slice(text, start, max, false).unwrap()
|
||||
render_slice(text, start, max).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -270,27 +254,18 @@ mod tests {
|
||||
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, true).unwrap_err().message.contains("offset exceeds file length"));
|
||||
assert!(render_slice("a\nb", 3, 10, true).unwrap_err().message.contains("offset exceeds file length"));
|
||||
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_a_long_default_read() {
|
||||
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 reads_an_explicit_range_exactly_and_says_where_to_read_on() {
|
||||
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
|
||||
let window = |start, max| render_slice(&text, start, max, true).unwrap();
|
||||
assert_eq!(window(5, 3), "L5: line 5\nL6: line 6\nL7: line 7\n…4 more lines; read on with offset=8…");
|
||||
assert_eq!(window(1, 1), "L1: line 1\n…10 more lines; read on with offset=2…");
|
||||
assert_eq!(window(10, 3), "L10: line 10\nL11: line 11", "the end of the file needs no marker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuts_long_lines_at_utf16_units() {
|
||||
let long = "x".repeat(2100);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
|
||||
mod check;
|
||||
mod edit;
|
||||
mod editors;
|
||||
mod requests;
|
||||
pub(crate) mod run;
|
||||
mod run;
|
||||
mod vcs;
|
||||
pub use requests::Requests;
|
||||
mod files;
|
||||
@@ -17,7 +16,6 @@ use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeError};
|
||||
use ide_mcp::{ToolCallError, ToolRegistry};
|
||||
pub use reports::{FileProblems, FocusedEditor, Reports};
|
||||
pub use run::agent::{AGENT_PARAM, AgentRun, AgentRuns, NAME_PREFIX, SHELL_PARAM};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -32,7 +30,6 @@ fn tools() -> Vec<tool::Tool> {
|
||||
let mut tools = files::tools();
|
||||
tools.extend(search::tools());
|
||||
tools.extend(editors::tools());
|
||||
tools.push(check::check_files());
|
||||
tools.push(symbols::get_symbol_info());
|
||||
tools.push(symbols::find_references());
|
||||
tools.extend(edit::tools());
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,418 +0,0 @@
|
||||
//! `run_command` and the tools that follow its tab up: `wait_for_run`, `read_run_output`,
|
||||
//! `get_run_status`, `send_run_input`, `stop_run` and `list_runs` (see [`super::agent`]).
|
||||
|
||||
use super::agent::{AgentRun, INTERVENTION_ADVICE, TruncateMode, clean_output, tab_name, truncate_lines};
|
||||
use super::output::RUN_PREVIEW_MAX_LENGTH;
|
||||
use crate::mcp::run::timeout;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::{Map, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
const RUN_TIMEOUT_MS: usize = 10_000;
|
||||
const WAIT_TIMEOUT_MS: usize = 30_000;
|
||||
const STOP_TIMEOUT_MS: usize = 10_000;
|
||||
/// Lines of output returned at most, by default (as JetBrains' `execute_terminal_command`).
|
||||
const DEFAULT_MAX_LINES: usize = 2000;
|
||||
/// Bytes of the log read for one result at most.
|
||||
const READ_CAP: usize = 1 << 20;
|
||||
/// Lines of output quoted when a name is still running.
|
||||
const BUSY_TAIL_LINES: usize = 20;
|
||||
|
||||
const NAME: &str = "Tab name. OpenIDEA shows the tab as `_agent_<name>`; passing the prefixed name works too.";
|
||||
const SEQUENCE: &str = "\n\nUsing the same name for every command of one task reuses its tab: a name whose command \
|
||||
has finished starts the next command in the same tab. A name whose command is still running is refused (with that \
|
||||
command and its last output); stop it with `stop_run` first or use another name. Nothing is stopped implicitly.";
|
||||
const INTERVENTIONS: &str = "\n\nThe user sees every tab and can type into it, rerun or stop its command, or close \
|
||||
it. Results then carry `interventions` (for example `User input: …` or `The user reran this command at …`) and \
|
||||
`advice`: unless the user asked otherwise, stop what you are doing, tell the user you received the intervention and \
|
||||
wait for their confirmation before you continue.";
|
||||
const OUTPUT: &str = "\n\nThe command runs on pipes, not a terminal, so programs print plain lines (no progress \
|
||||
bars). `output` is that text cleaned of escape sequences, at most `maxLinesCount` lines (default 2000): beyond it, \
|
||||
`truncateMode` START (default) keeps the end, END the start, MIDDLE both ends, NONE everything, with a \
|
||||
`<<<...content truncated...>>>` line where lines were left out and `truncatedLines` saying how many. `outputStart` \
|
||||
and `nextOffset` are positions in the tab's raw output log; pass `nextOffset` as `offset` to read on.";
|
||||
const MAX_LINES: &str = "Lines of output to return at most (default 2000)";
|
||||
const TRUNCATE_MODE: &str = "Which lines to leave out beyond maxLinesCount: START (default, keeps the end), END \
|
||||
(keeps the start), MIDDLE (keeps both ends) or NONE";
|
||||
|
||||
fn find(call: &Call) -> IdeResult<Arc<AgentRun>> {
|
||||
let name = tab_name(&call.str("name")?)?;
|
||||
call.host()
|
||||
.mcp_agent_runs
|
||||
.get(call.root(), &name)
|
||||
.ok_or_else(|| IdeError::not_found(format!("no agent tab named `{name}` (see list_runs)")))
|
||||
}
|
||||
|
||||
/// `status` of `run`, with interventions not told yet.
|
||||
fn result(run: &AgentRun, extra: Map<String, Value>) -> IdeResult<Output> {
|
||||
let mut out = run.status();
|
||||
out.extend(extra);
|
||||
let interventions = run.take_interventions();
|
||||
if !interventions.is_empty() {
|
||||
out.insert("interventions".into(), interventions.into());
|
||||
out.insert("advice".into(), INTERVENTION_ADVICE.into());
|
||||
}
|
||||
Output::json(Value::Object(out))
|
||||
}
|
||||
|
||||
/// The output from `from` as the agent reads it: cleaned, then cut to `maxLinesCount` lines
|
||||
/// as `truncateMode` says.
|
||||
fn output_view(call: &Call, run: &AgentRun, from: u64) -> IdeResult<Map<String, Value>> {
|
||||
let max_lines = match call.opt_i64("maxLinesCount")? {
|
||||
Some(n) if n < 1 => return Err(IdeError::invalid("maxLinesCount must be > 0")),
|
||||
Some(n) => n as usize,
|
||||
None => DEFAULT_MAX_LINES,
|
||||
};
|
||||
let mode = match call.opt_str("truncateMode")? {
|
||||
Some(name) => TruncateMode::parse(&name)
|
||||
.ok_or_else(|| IdeError::invalid("truncateMode must be START, MIDDLE, END or NONE"))?,
|
||||
None => TruncateMode::Start,
|
||||
};
|
||||
let len = run.log_len();
|
||||
let from = from.min(len);
|
||||
// A long output is read from the end that stays.
|
||||
let start = if mode == TruncateMode::Start && len - from > READ_CAP as u64 { len - READ_CAP as u64 } else { from };
|
||||
let (raw, start, end) = run.read(start, READ_CAP)?;
|
||||
let (text, truncated) = truncate_lines(&clean_output(&raw), max_lines, mode);
|
||||
let mut out = Map::new();
|
||||
out.insert("output".into(), text.into());
|
||||
if truncated > 0 {
|
||||
out.insert("truncatedLines".into(), truncated.into());
|
||||
}
|
||||
out.insert("outputStart".into(), start.into());
|
||||
out.insert("nextOffset".into(), end.into());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn deadline(call: &Call, default: usize) -> IdeResult<tokio::time::Instant> {
|
||||
Ok(tokio::time::Instant::now() + timeout(call, default)?)
|
||||
}
|
||||
|
||||
fn pattern(call: &Call) -> IdeResult<Option<regex_lite::Regex>> {
|
||||
call.opt_str("pattern")?
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| regex_lite::Regex::new(&p).map_err(|e| IdeError::invalid(format!("invalid pattern: {e}"))))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn offset(call: &Call, name: &str) -> IdeResult<Option<u64>> {
|
||||
match call.opt_i64(name)? {
|
||||
Some(n) if n < 0 => Err(IdeError::invalid(format!("{name} must not be negative"))),
|
||||
n => Ok(n.map(|n| n as u64)),
|
||||
}
|
||||
}
|
||||
|
||||
fn busy(run: &AgentRun) -> IdeError {
|
||||
let tail = run.tail(run.process_offset(), BUSY_TAIL_LINES, RUN_PREVIEW_MAX_LENGTH).map(|t| t.0).unwrap_or_default();
|
||||
IdeError::invalid(format!(
|
||||
"`{}` is still running `{}`. Stop it with stop_run first, or use another name.\nLast output:\n{}",
|
||||
run.name,
|
||||
run.command(),
|
||||
clean_output(&tail).trim_end()
|
||||
))
|
||||
}
|
||||
|
||||
pub fn run_command() -> Tool {
|
||||
Tool::new(
|
||||
"run_command",
|
||||
&format!(
|
||||
"Runs a shell command line in a new or reused tab of OpenIDEA's Run tool window, with the shell of \
|
||||
Settings › Tools › Terminal. The user is asked first (unless the IDE runs tools without \
|
||||
confirmation). Waits up to `timeout` milliseconds (default {RUN_TIMEOUT_MS}) for the command to finish; a command \
|
||||
still running then keeps running, and `wait_for_run`, `read_run_output`, `get_run_status`, `send_run_input` and \
|
||||
`stop_run` follow it up by name. Returns the tab's status (`state`: starting, running, stopping, succeeded, failed, \
|
||||
crashed, stoppedByAgent, stoppedByUser or startFailed; `exitCode`; `crash` with the signal or exception of a crashed \
|
||||
process) and its output so far.{OUTPUT}{SEQUENCE}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let name = tab_name(&call.str("name")?)?;
|
||||
let command = call.str("command")?;
|
||||
if command.trim().is_empty() {
|
||||
return Err(IdeError::invalid("command must not be empty"));
|
||||
}
|
||||
let cwd = match call.opt_str("workingDirectory")?.filter(|d| !d.trim().is_empty()) {
|
||||
Some(dir) => {
|
||||
let path = call.resolve_inside(&dir)?;
|
||||
if !path.is_dir() {
|
||||
return Err(IdeError::not_found(format!("workingDirectory {} is not a directory", dir)));
|
||||
}
|
||||
path
|
||||
}
|
||||
None => call.root().to_path_buf(),
|
||||
};
|
||||
let env = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
|
||||
let deadline = deadline(&call, RUN_TIMEOUT_MS)?;
|
||||
let host = call.host();
|
||||
let runs = &host.mcp_agent_runs;
|
||||
if let Some(run) = runs.get(call.root(), &name).filter(|r| r.running()) {
|
||||
return Err(busy(&run));
|
||||
}
|
||||
let shown_cwd = call.display(&cwd);
|
||||
let mut detail = format!(
|
||||
"{command}\n\nTab: {name}\nWorking directory: {}",
|
||||
if shown_cwd.is_empty() { ".".to_owned() } else { shown_cwd.clone() }
|
||||
);
|
||||
for (key, value) in env.iter().flatten() {
|
||||
detail.push_str(&format!("\n{key}={value}"));
|
||||
}
|
||||
call.confirm(&name, Some(detail)).await?;
|
||||
let run = runs.prepare(call.root(), call.window(), &name, &command, &shown_cwd).map_err(|r| busy(&r))?;
|
||||
let request = ide_core::event::McpUiRequest::AgentRun {
|
||||
name: name.clone(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.into(),
|
||||
env: env.unwrap_or_default().into_iter().collect(),
|
||||
};
|
||||
if let Err(e) = call.ask(request).await {
|
||||
let error = run.start_error();
|
||||
runs.abandon(&run);
|
||||
return Err(match error {
|
||||
Some(error) => IdeError::invalid(format!("`{name}` did not start: {error}")),
|
||||
None => e,
|
||||
});
|
||||
}
|
||||
if let Some(error) = run.start_error() {
|
||||
return Err(IdeError::invalid(format!("`{name}` did not start: {error}")));
|
||||
}
|
||||
let from = run.process_offset();
|
||||
let end = run.wait(deadline, None, from, &call.cancel).await?;
|
||||
let mut extra = output_view(&call, &run, from)?;
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::required(
|
||||
"command",
|
||||
ParamType::String,
|
||||
"Shell command line, e.g. `npm test -- --watch=false` or `cargo build && ./target/debug/app`",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"workingDirectory",
|
||||
ParamType::String,
|
||||
"Directory inside the project to run in, relative to the project root; default: the project root",
|
||||
))
|
||||
.param(Param::optional("envs", ParamType::StringMap, "Environment variables added for this command"))
|
||||
.param(Param::optional(
|
||||
"timeout",
|
||||
ParamType::Integer,
|
||||
"Milliseconds to wait for the command to finish before returning (0 returns once it started); the command \
|
||||
keeps running after it",
|
||||
))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn wait_for_run() -> Tool {
|
||||
Tool::new(
|
||||
"wait_for_run",
|
||||
&format!(
|
||||
"Waits until the command of an agent tab finishes, its output matches `pattern`, the user intervenes, or \
|
||||
`timeout` milliseconds pass (default {WAIT_TIMEOUT_MS}). Cancelling the wait leaves the command running. Returns \
|
||||
`waitEnded` (exited, matched, userIntervened or timedOut), the tab's status and the output since `offset`. Use \
|
||||
`pattern` to wait for a server to be ready, e.g. `Listening on|Started .* in`.{OUTPUT}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let deadline = deadline(&call, WAIT_TIMEOUT_MS)?;
|
||||
let pattern = pattern(&call)?;
|
||||
let from = offset(&call, "offset")?.unwrap_or_else(|| run.process_offset());
|
||||
let end = run.wait(deadline, pattern.as_ref(), from, &call.cancel).await?;
|
||||
let mut extra = output_view(&call, &run, from)?;
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait at most"))
|
||||
.param(Param::optional(
|
||||
"pattern",
|
||||
ParamType::String,
|
||||
"Regular expression to wait for in the output after `offset` (as the command wrote it)",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"offset",
|
||||
ParamType::Integer,
|
||||
"Where in the tab's output log to start: a `nextOffset` from an earlier result. Default: where the current \
|
||||
command's output starts",
|
||||
))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn read_run_output() -> Tool {
|
||||
Tool::new(
|
||||
"read_run_output",
|
||||
&format!(
|
||||
"Reads the output log of an agent tab, which goes on across the commands and reruns of the tab: from \
|
||||
`offset` (a `nextOffset` from an earlier result), or from its start. Also returns the tab's status.\
|
||||
{OUTPUT}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let from = offset(&call, "offset")?.unwrap_or(0);
|
||||
let extra = output_view(&call, &run, from)?;
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("offset", ParamType::Integer, "Where in the log to read from"))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn get_run_status() -> Tool {
|
||||
Tool::new(
|
||||
"get_run_status",
|
||||
&format!(
|
||||
"Returns the status of an agent tab: its command, `state` (starting, running, stopping, succeeded, \
|
||||
failed, crashed, stoppedByAgent, stoppedByUser or startFailed), `startedBy` (agent, or user when the user reran \
|
||||
it), times, `exitCode`, `crash`, `outputLength`, `lastOutputAt` (a running command silent for long may be waiting \
|
||||
for input) and `tabClosedAt` when the user closed the tab.{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
result(&run, Map::new())
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
}
|
||||
|
||||
pub fn list_runs() -> Tool {
|
||||
Tool::new(
|
||||
"list_runs",
|
||||
"Lists the agent tabs of the project (those started by `run_command`) with their status.",
|
||||
|call| async move {
|
||||
let runs: Vec<Value> =
|
||||
call.host().mcp_agent_runs.list(call.root()).iter().map(|r| Value::Object(r.status())).collect();
|
||||
Output::json(serde_json::json!({ "runs": runs }))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// What a key does to a command on pipes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Key {
|
||||
/// Writes this text to its input.
|
||||
Text(&'static str),
|
||||
/// Ctrl+C: stops it, as `stop_run` does.
|
||||
Interrupt,
|
||||
/// Ctrl+D: ends its input.
|
||||
EndOfInput,
|
||||
}
|
||||
|
||||
fn key(name: &str) -> IdeResult<Key> {
|
||||
Ok(match name.trim().to_ascii_lowercase().replace(' ', "").as_str() {
|
||||
"enter" | "return" => Key::Text("\n"),
|
||||
"tab" => Key::Text("\t"),
|
||||
"ctrl+c" => Key::Interrupt,
|
||||
"ctrl+d" => Key::EndOfInput,
|
||||
"escape" | "esc" | "backspace" | "ctrl+z" | "ctrl+\\" | "ctrl+l" | "up" | "down" | "left" | "right" => {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"`{name}` is a terminal key, and the command runs on pipes, not a terminal: send text and Enter, \
|
||||
Ctrl+C to stop it or Ctrl+D to end its input"
|
||||
)));
|
||||
}
|
||||
_ => return Err(IdeError::invalid(format!("unknown key `{name}`"))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send_run_input() -> Tool {
|
||||
Tool::new(
|
||||
"send_run_input",
|
||||
"Writes to the input of the running command of an agent tab, which runs on pipes: `text` as it is, then \
|
||||
`keys` in order: Enter (a line break), Tab, Ctrl+C (stops the command, as `stop_run` does) and Ctrl+D (ends its \
|
||||
input). Terminal keys (arrows, Escape, Backspace, Ctrl+Z…) are refused. To answer a prompt, pass the answer as \
|
||||
`text` and `keys: [\"Enter\"]`. The user sees the command's output in the tab. Returns the tab's status.",
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let text = call.opt_str("text")?.unwrap_or_default();
|
||||
let keys = call.str_list("keys")?.iter().map(|k| key(k)).collect::<IdeResult<Vec<_>>>()?;
|
||||
if text.is_empty() && keys.is_empty() {
|
||||
return Err(IdeError::invalid("pass `text`, `keys` or both"));
|
||||
}
|
||||
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
|
||||
call.project().require_trust()?;
|
||||
let host = call.host();
|
||||
let mut data = text;
|
||||
for key in keys {
|
||||
match key {
|
||||
Key::Text(text) => data.push_str(text),
|
||||
Key::EndOfInput | Key::Interrupt => {
|
||||
if !data.is_empty() {
|
||||
host.processes.write_stdin(id, &std::mem::take(&mut data))?;
|
||||
}
|
||||
if key == Key::EndOfInput {
|
||||
crate::ops::run::close_stdin(&host, id)?;
|
||||
} else {
|
||||
run.agent_stopping();
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !data.is_empty() {
|
||||
host.processes.write_stdin(id, &data)?;
|
||||
}
|
||||
result(&run, Map::new())
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("text", ParamType::String, "Text to type"))
|
||||
.param(Param::optional("keys", ParamType::StringList, "Keys to press after `text`"))
|
||||
}
|
||||
|
||||
pub fn stop_run() -> Tool {
|
||||
Tool::new(
|
||||
"stop_run",
|
||||
&format!(
|
||||
"Stops the running command of an agent tab, the way the Run tool window's Stop does: it asks the \
|
||||
process tree to exit and, with `force`, kills it. Waits up to `timeout` milliseconds (default {STOP_TIMEOUT_MS}) \
|
||||
for it to end and returns the tab's status (`stoppedByAgent` once it ended). A process that ignores the request \
|
||||
keeps `stopping`: call again with `force`. The tab stays open with its output."
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
|
||||
let force = call.flag("force", false)?;
|
||||
let deadline = deadline(&call, STOP_TIMEOUT_MS)?;
|
||||
let host = call.host();
|
||||
run.agent_stopping();
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
if force {
|
||||
// A second Stop kills the tree.
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
}
|
||||
let end = run.wait(deadline, None, run.log_len(), &call.cancel).await?;
|
||||
let mut extra = Map::new();
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("force", ParamType::Boolean, "Kill the process tree instead of asking it to exit"))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the command to end"))
|
||||
}
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![run_command(), wait_for_run(), read_run_output(), get_run_status(), send_run_input(), stop_run(), list_runs()]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keys_are_what_pipes_can_take() {
|
||||
assert_eq!(key(" enter ").unwrap(), Key::Text("\n"));
|
||||
assert_eq!(key("Ctrl+C").unwrap(), Key::Interrupt);
|
||||
assert_eq!(key("ctrl+d").unwrap(), Key::EndOfInput);
|
||||
assert!(key("Up").unwrap_err().message.contains("runs on pipes"));
|
||||
assert!(key("F13").unwrap_err().message.contains("unknown key"));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -83,13 +84,13 @@ You have to use this tool after performing edits to validate if the edits are va
|
||||
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 (0 times out at once) the build is stopped.",
|
||||
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)?;
|
||||
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 {
|
||||
|
||||
@@ -19,6 +19,8 @@ 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.
|
||||
@@ -539,7 +541,7 @@ tool window; output files are private and deleted after a day.",
|
||||
)?;
|
||||
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)?) } else { None };
|
||||
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 {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Run configurations, builds and agent commands; every start asks the user first.
|
||||
//! Run configurations and builds; every call asks the user first.
|
||||
|
||||
pub(crate) mod agent;
|
||||
mod agent_tools;
|
||||
mod build;
|
||||
mod configs;
|
||||
mod output;
|
||||
@@ -13,22 +11,15 @@ use ide_core::IdeResult;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
let mut tools =
|
||||
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()];
|
||||
tools.extend(agent_tools::tools());
|
||||
tools
|
||||
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()]
|
||||
}
|
||||
|
||||
/// Longest wait: far enough to be no limit, near enough not to overflow a deadline.
|
||||
const FOREVER_MS: u64 = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/// The `timeout` argument in milliseconds, `default` when absent: the agent decides how long
|
||||
/// to wait (long waits are answered on an event stream, see `ide_mcp`). As in JetBrains
|
||||
/// The `timeout` argument: `default` when absent, at most `max` milliseconds. As in JetBrains
|
||||
/// (`withTimeoutOrNull`), zero or a negative value times out at once.
|
||||
pub(crate) fn timeout(call: &Call, default: usize) -> IdeResult<Duration> {
|
||||
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(FOREVER_MS)),
|
||||
Some(n) => Duration::from_millis((n as u64).min(max as u64)),
|
||||
})
|
||||
}
|
||||
@@ -434,23 +434,10 @@ fn cwd_of(root: &Path, c: &RunConfig) -> String {
|
||||
cwd.trim_end_matches('/').to_owned()
|
||||
}
|
||||
|
||||
/// The directory a config covers: a Cargo workspace member's own directory (its `cargo test -p`
|
||||
/// runs from the workspace root), else its working directory.
|
||||
fn scope_of(root: &Path, c: &RunConfig) -> String {
|
||||
match c.extras.module.as_deref() {
|
||||
Some(module) if c.kind == "cargo" => {
|
||||
let module = module.replace('\\', "/");
|
||||
let module = module.strip_prefix("./").unwrap_or(&module);
|
||||
if module == "." { String::new() } else { module.trim_end_matches('/').to_owned() }
|
||||
}
|
||||
_ => cwd_of(root, c),
|
||||
}
|
||||
}
|
||||
|
||||
/// Configs whose scope ([`scope_of`]) most specifically contains `rel` (ties in their original order).
|
||||
/// 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 = scope_of(root, c);
|
||||
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();
|
||||
@@ -935,33 +922,6 @@ mod tests {
|
||||
assert_eq!(c.args, ["test", "--", "math::tests::"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_workspace_member_runs_its_own_tests() {
|
||||
let root = Path::new("/p");
|
||||
let member = |pkg: &str, dir: &str| {
|
||||
let mut c = blank(
|
||||
format!("cargo:test:{pkg}"),
|
||||
"test",
|
||||
"cargo",
|
||||
"cargo".into(),
|
||||
vec!["test".into(), "-p".into(), pkg.into()],
|
||||
);
|
||||
c.is_test = true;
|
||||
c.extras.module = Some(dir.into());
|
||||
c
|
||||
};
|
||||
let mut workspace = blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into()]);
|
||||
workspace.is_test = true;
|
||||
let configs = vec![member("ide-agent", "crates/ide-agent"), member("ide-mcp", "crates/ide-mcp"), workspace];
|
||||
let src = "#[cfg(test)]\nmod tests {\n #[test]\n fn adds() {}\n}\n";
|
||||
let rel = "crates/ide-mcp/src/stdio.rs";
|
||||
let ts = detect(src, rel);
|
||||
let c = target_config(root, &ts[1], rel, &configs, src).unwrap();
|
||||
assert_eq!(c.args, ["test", "-p", "ide-mcp", "--", "stdio::tests::adds", "--exact"]);
|
||||
let c = target_config(root, &ts[1], "src/lib.rs", &configs, src).unwrap();
|
||||
assert!(!c.args.iter().any(|a| a == "-p"), "outside every member: the workspace's tests, {:?}", c.args);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_test_args_get_one_separator() {
|
||||
let root = Path::new("/p");
|
||||
|
||||
@@ -38,14 +38,6 @@ 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";
|
||||
const CONTEXT_DESCRIPTION: &str = "Lines around each match to return besides the matched line (`lineText`): \"+N\" the \
|
||||
N lines after it (`contextAfter`), \"-N\" the N lines before it (`contextBefore`), \"N\" (or the number N) N lines on \
|
||||
each side. At most 50. Default: the matched line only.";
|
||||
/// A matched line longer than this (UTF-16 units) is cut to the match and [`SNIPPET_AROUND`] units
|
||||
/// on each side of it; a longer context line to its start.
|
||||
const MAX_LINE_TEXT: usize = 300;
|
||||
const SNIPPET_AROUND: usize = 100;
|
||||
const MAX_CONTEXT_LINES: u32 = 50;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![search_text(), search_regex(), search_symbol(), search_file()]
|
||||
@@ -57,8 +49,6 @@ fn search_text() -> Tool {
|
||||
"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).
|
||||
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
|
||||
with …), and with `context` the lines around it.
|
||||
|
||||
Paths are glob patterns relative to the project root.
|
||||
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|
||||
@@ -67,7 +57,6 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|
||||
.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))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
@@ -77,8 +66,6 @@ fn search_regex() -> Tool {
|
||||
"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).
|
||||
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
|
||||
with …), and with `context` the lines around it.
|
||||
The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case.
|
||||
|
||||
Paths are glob patterns relative to the project root.
|
||||
@@ -88,7 +75,6 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|
||||
.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))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
@@ -98,7 +84,6 @@ fn search_symbol() -> Tool {
|
||||
"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).
|
||||
Project results have their line (`lineText`), and with `context` the lines around it.
|
||||
|
||||
Paths are glob patterns relative to the project root.
|
||||
By default this searches project symbols only.
|
||||
@@ -114,7 +99,6 @@ If you don't find a suitable result, try again with include_external=true to sea
|
||||
with include_external=true.",
|
||||
))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
@@ -161,13 +145,6 @@ struct SearchItem {
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
container: Option<String>,
|
||||
/// The text of `start_line` (cut when long).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
line_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
context_before: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
context_after: Vec<String>,
|
||||
}
|
||||
|
||||
impl SearchItem {
|
||||
@@ -205,9 +182,7 @@ fn result_schema() -> Value {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
|
||||
"name": string, "container": string, "lineText": string,
|
||||
"contextBefore": { "type": "array", "items": string },
|
||||
"contextAfter": { "type": "array", "items": string },
|
||||
"name": string, "container": string,
|
||||
},
|
||||
"required": ["filePath"],
|
||||
} },
|
||||
@@ -232,87 +207,6 @@ fn query(call: &Call, empty: &str) -> IdeResult<String> {
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
// --- matched lines -------------------------------------------------------------
|
||||
|
||||
/// The `context` argument as (lines before, lines after).
|
||||
fn context(call: &Call) -> IdeResult<(u32, u32)> {
|
||||
let invalid = || IdeError::invalid("context must be \"+N\", \"-N\" or N (lines after, before, or on each side)");
|
||||
let (sign, n) = match call.args.get("context") {
|
||||
None | Some(Value::Null) => return Ok((0, 0)),
|
||||
Some(Value::Number(n)) => (None, n.as_u64().ok_or_else(invalid)?),
|
||||
Some(Value::String(s)) => {
|
||||
let s = s.trim();
|
||||
match s.strip_prefix('+').map(|r| ('+', r)).or_else(|| s.strip_prefix('-').map(|r| ('-', r))) {
|
||||
Some((sign, rest)) => (Some(sign), rest.trim().parse().map_err(|_| invalid())?),
|
||||
None => (None, s.parse().map_err(|_| invalid())?),
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(invalid()),
|
||||
};
|
||||
let n = (n.min(MAX_CONTEXT_LINES as u64)) as u32;
|
||||
Ok(match sign {
|
||||
Some('+') => (0, n),
|
||||
Some(_) => (n, 0),
|
||||
None => (n, n),
|
||||
})
|
||||
}
|
||||
|
||||
/// `line`, or when longer than [`MAX_LINE_TEXT`] UTF-16 units, the part from [`SNIPPET_AROUND`]
|
||||
/// units before 0-based UTF-16 column `start` to as many after `end`, with `…` where it was cut.
|
||||
fn snippet(line: &str, start: usize, end: usize) -> String {
|
||||
let units: usize = line.chars().map(char::len_utf16).sum();
|
||||
if units <= MAX_LINE_TEXT {
|
||||
return line.to_owned();
|
||||
}
|
||||
let from = start.saturating_sub(SNIPPET_AROUND);
|
||||
let to = end.max(start).saturating_add(SNIPPET_AROUND).min(units);
|
||||
let (mut out, mut at) = (String::new(), 0);
|
||||
for c in line.chars() {
|
||||
if at >= from && at < to {
|
||||
out.push(c);
|
||||
}
|
||||
at += c.len_utf16();
|
||||
}
|
||||
let before = if from > 0 { "\u{2026}" } else { "" };
|
||||
let after = if to < units { "\u{2026}" } else { "" };
|
||||
format!("{before}{out}{after}")
|
||||
}
|
||||
|
||||
/// A context line: its first [`MAX_LINE_TEXT`] UTF-16 units.
|
||||
fn context_line(line: &str) -> String {
|
||||
snippet(line, 0, MAX_LINE_TEXT.saturating_sub(SNIPPET_AROUND))
|
||||
}
|
||||
|
||||
/// Fills `line_text` and the context of `items`, whose files are `absolute(item)` (none: left
|
||||
/// as they are); each file is read once, with unsaved editor text.
|
||||
async fn fill_lines(
|
||||
call: &Call,
|
||||
items: &mut [SearchItem],
|
||||
absolute: impl Fn(&SearchItem) -> Option<PathBuf>,
|
||||
(before, after): (u32, u32),
|
||||
) {
|
||||
let host = call.host();
|
||||
let mut current: Option<(PathBuf, Option<Vec<String>>)> = None;
|
||||
for item in items {
|
||||
let (Some(path), Some(line)) = (absolute(item), item.start_line) else { continue };
|
||||
if current.as_ref().is_none_or(|(p, _)| p != &path) {
|
||||
let text = crate::ops::fs::read_text(&host, call.window(), path.clone()).await.ok();
|
||||
let lines = text.map(|t| t.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l).to_owned()).collect());
|
||||
current = Some((path, lines));
|
||||
}
|
||||
let Some((_, Some(lines))) = ¤t else { continue };
|
||||
let index = line as usize - 1;
|
||||
let Some(text) = lines.get(index) else { continue };
|
||||
let start = item.start_column.unwrap_or(1) as usize - 1;
|
||||
let end = if item.end_line == Some(line) { item.end_column.map_or(start, |c| c as usize - 1) } else { start };
|
||||
item.line_text = Some(snippet(text, start, end));
|
||||
let first = index.saturating_sub(before as usize);
|
||||
item.context_before = lines[first..index].iter().map(|l| context_line(l)).collect();
|
||||
let last = (index + after as usize).min(lines.len() - 1);
|
||||
item.context_after = lines[index + 1..=last].iter().map(|l| context_line(l)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
// --- path globs ----------------------------------------------------------------
|
||||
|
||||
/// The `paths` argument: project-relative globs, `!` excludes.
|
||||
@@ -522,7 +416,6 @@ struct TextHit {
|
||||
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 around = context(&call)?;
|
||||
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.
|
||||
@@ -601,7 +494,7 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
|
||||
let more = hits.len() > limit || summary.limit_hit || timed_out;
|
||||
hits.truncate(limit);
|
||||
extend_spanning(&call, &query, &mut hits, spanning).await;
|
||||
let mut items: Vec<SearchItem> = hits
|
||||
let items = hits
|
||||
.into_iter()
|
||||
.map(|h| SearchItem {
|
||||
file_path: h.file_path,
|
||||
@@ -612,9 +505,6 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let absolute: std::collections::HashMap<&str, &Path> =
|
||||
files.iter().map(|(path, file)| (path.as_str(), file.path.0.as_path())).collect();
|
||||
fill_lines(&call, &mut items, |item| absolute.get(item.file_path.as_str()).map(|p| p.to_path_buf()), around).await;
|
||||
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
|
||||
Output::json(SearchResult { items, more, partial_result_reason })
|
||||
}
|
||||
@@ -836,7 +726,6 @@ 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 around = context(&call)?;
|
||||
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
|
||||
let external = call.flag("include_external", false)?;
|
||||
let host = call.host();
|
||||
@@ -886,13 +775,6 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
|
||||
}
|
||||
let more = items.len() > limit || timed_out;
|
||||
items.truncate(limit);
|
||||
// Project files (relative paths, or absolute ones of content roots); library URIs are left out.
|
||||
let root = call.root().to_path_buf();
|
||||
let absolute = |item: &SearchItem| {
|
||||
let path = Path::new(&item.file_path);
|
||||
(!item.file_path.contains("://")).then(|| if path.is_absolute() { path.to_path_buf() } else { root.join(path) })
|
||||
};
|
||||
fill_lines(&call, &mut items, absolute, around).await;
|
||||
let indexing = indexing_before || project.index.status().indexing;
|
||||
let partial_result_reason = if indexing {
|
||||
Some(INDEXING_PARTIAL_RESULT_REASON)
|
||||
@@ -987,17 +869,6 @@ mod tests {
|
||||
assert_eq!(byte_at("ab", 5), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_lines_are_cut_around_the_match() {
|
||||
assert_eq!(snippet("short line", 0, 5), "short line");
|
||||
let line = format!("{}needle{}", "a".repeat(400), "b".repeat(400));
|
||||
let cut = snippet(&line, 400, 406);
|
||||
assert_eq!(cut, format!("\u{2026}{}needle{}\u{2026}", "a".repeat(100), "b".repeat(100)));
|
||||
let start = snippet(&line, 0, 0);
|
||||
assert!(start.starts_with('a') && start.ends_with('\u{2026}') && start.chars().count() == 101);
|
||||
assert_eq!(context_line(&"x".repeat(1000)).chars().count(), MAX_LINE_TEXT + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_scopes() {
|
||||
let (_dir, root) = root();
|
||||
|
||||
@@ -381,7 +381,7 @@ fn persist_mcp_map(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_mcp_maps<F>(host: &Host, window: &str, approve: Option<&str>, f: F) -> IdeResult<Vec<McpServer>>
|
||||
fn update_mcp_maps<F>(host: &Host, window: &str, f: F) -> IdeResult<Vec<McpServer>>
|
||||
where
|
||||
F: FnOnce(&mut McpServerMap, &mut McpServerMap, Option<&std::path::Path>) -> Result<(), String>,
|
||||
{
|
||||
@@ -390,83 +390,43 @@ where
|
||||
let prev_workspace = workspace.clone();
|
||||
let project_root = project.as_ref().map(|p| p.root.as_path());
|
||||
f(&mut user, &mut workspace, project_root).map_err(IdeError::invalid)?;
|
||||
if let (Some(name), Some(p)) = (approve, &project)
|
||||
&& let Some(server) = workspace.get(name)
|
||||
{
|
||||
host.mcp_approvals.approve(&p.root, name, server)?;
|
||||
}
|
||||
if workspace != prev_workspace {
|
||||
persist_mcp_map(host, project.as_ref(), McpScope::Workspace, &workspace)?;
|
||||
}
|
||||
if user != prev_user {
|
||||
persist_mcp_map(host, project.as_ref(), McpScope::User, &user)?;
|
||||
}
|
||||
Ok(listed(host, project.as_ref(), &user, workspace))
|
||||
}
|
||||
|
||||
/// Workspace servers the user hasn't turned on in Settings are listed as off: agents don't start them.
|
||||
fn listed(
|
||||
host: &Host,
|
||||
project: Option<&std::sync::Arc<crate::Project>>,
|
||||
user: &McpServerMap,
|
||||
workspace: McpServerMap,
|
||||
) -> Vec<McpServer> {
|
||||
let workspace = match project {
|
||||
Some(p) => host.mcp_approvals.apply(&p.root, workspace),
|
||||
None => workspace,
|
||||
};
|
||||
ide_agent::mcp::servers_from_settings(user, &workspace)
|
||||
}
|
||||
|
||||
/// The MCP servers agent sessions of the project at `root` get: the user's, and the project's the
|
||||
/// user turned on.
|
||||
pub(crate) fn session_mcp_servers(
|
||||
settings: &ide_workspace::SettingsStore,
|
||||
approvals: &crate::trust::McpServerApprovals,
|
||||
root: &std::path::Path,
|
||||
) -> Vec<McpServer> {
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
let user = settings.layer(SettingsScope::Global).agents.mcp_servers;
|
||||
let workspace = approvals.apply(root, settings.layer(SettingsScope::Project).agents.mcp_servers);
|
||||
ide_agent::mcp::servers_from_settings(&user, &workspace)
|
||||
Ok(ide_agent::mcp::servers_from_settings(&user, &workspace))
|
||||
}
|
||||
|
||||
/// Lists configured MCP servers across User (`settings.toml`) and Workspace (`.openidea/settings.toml`) scopes.
|
||||
pub fn mcp_list(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
|
||||
let (user, workspace, project) = mcp_maps(host, window);
|
||||
Ok(listed(host, project.as_ref(), &user, workspace))
|
||||
let (user, workspace, _) = mcp_maps(host, window);
|
||||
Ok(ide_agent::mcp::servers_from_settings(&user, &workspace))
|
||||
}
|
||||
|
||||
/// Creates or updates a User or Workspace MCP server and returns the updated list. A Workspace
|
||||
/// server saved here is one the user turned on.
|
||||
/// Creates or updates a User or Workspace MCP server and returns the updated list.
|
||||
pub fn mcp_save(host: &Host, window: &str, input: McpServerInput) -> IdeResult<Vec<McpServer>> {
|
||||
let approve = (input.scope == McpScope::Workspace).then(|| input.name.trim().to_owned());
|
||||
update_mcp_maps(host, window, approve.as_deref(), |user, workspace, project_root| {
|
||||
update_mcp_maps(host, window, |user, workspace, project_root| {
|
||||
ide_agent::mcp::apply_save(user, workspace, project_root.is_some(), input)
|
||||
})
|
||||
}
|
||||
|
||||
/// Enables or disables an MCP server and returns the updated list.
|
||||
pub fn mcp_set_enabled(host: &Host, window: &str, id: String, enabled: bool) -> IdeResult<Vec<McpServer>> {
|
||||
let approve = ide_agent::mcp::parse_server_id(&id)
|
||||
.filter(|(scope, _)| enabled && *scope == McpScope::Workspace)
|
||||
.map(|(_, name)| name.to_owned());
|
||||
update_mcp_maps(host, window, approve.as_deref(), |user, workspace, _| {
|
||||
update_mcp_maps(host, window, |user, workspace, _| {
|
||||
ide_agent::mcp::apply_set_enabled(user, workspace, &id, enabled).map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes a User or Workspace MCP server and returns the updated list.
|
||||
pub fn mcp_remove(host: &Host, window: &str, id: String) -> IdeResult<Vec<McpServer>> {
|
||||
update_mcp_maps(host, window, None, |user, workspace, _| {
|
||||
ide_agent::mcp::apply_remove(user, workspace, &id).map(|_| ())
|
||||
})
|
||||
update_mcp_maps(host, window, |user, workspace, _| ide_agent::mcp::apply_remove(user, workspace, &id).map(|_| ()))
|
||||
}
|
||||
|
||||
/// Copies MCP servers from `.mcp.json` and `.vscode/mcp.json` into the workspace settings once;
|
||||
/// they stay off until the user turns them on.
|
||||
/// Copies MCP servers from `.mcp.json` and `.vscode/mcp.json` into the workspace settings once.
|
||||
pub fn mcp_import(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
|
||||
update_mcp_maps(host, window, None, |_, workspace, project_root| {
|
||||
update_mcp_maps(host, window, |_, workspace, project_root| {
|
||||
let root = project_root.ok_or_else(|| "Open a project to import workspace MCP servers".to_string())?;
|
||||
ide_agent::mcp::apply_import(workspace, root);
|
||||
Ok(())
|
||||
@@ -590,43 +550,4 @@ mod tests {
|
||||
assert!(validate_project_root(&root, Some(&rejected)).is_err(), "{rejected:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_get_the_users_servers_and_the_projects_turned_on_ones() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (config, root) = (tmp.path().join("config"), tmp.path().join("project"));
|
||||
std::fs::create_dir_all(&config).unwrap();
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
std::fs::write(config.join("settings.toml"), "[agents.mcpServers.shared]\ncommand = \"user-cmd\"\n").unwrap();
|
||||
std::fs::write(
|
||||
root.join(".openidea/settings.toml"),
|
||||
"[agents.mcpServers.shared]\ncommand = \"project-cmd\"\n\n[agents.mcpServers.extra]\ncommand = \"extra\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let settings = ide_workspace::SettingsStore::load(&config, Some(&root));
|
||||
let approvals = crate::trust::McpServerApprovals::load(&tmp.path().join("data"));
|
||||
let on = |servers: Vec<McpServer>| -> Vec<(McpScope, String, String)> {
|
||||
servers
|
||||
.into_iter()
|
||||
.filter(|s| s.enabled)
|
||||
.map(|s| (s.scope, s.name, s.command.unwrap_or_default()))
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
on(session_mcp_servers(&settings, &approvals, &root)),
|
||||
[(McpScope::User, "shared".to_owned(), "user-cmd".to_owned())]
|
||||
);
|
||||
|
||||
let extra = settings.layer(ide_workspace::settings::SettingsScope::Project).agents.mcp_servers["extra"].clone();
|
||||
approvals.approve(&root, "extra", &extra).unwrap();
|
||||
assert!(on(session_mcp_servers(&settings, &approvals, &root)).contains(&(
|
||||
McpScope::Workspace,
|
||||
"extra".to_owned(),
|
||||
"extra".to_owned()
|
||||
)));
|
||||
// Remembered across restarts, and only for this project.
|
||||
let reloaded = crate::trust::McpServerApprovals::load(&tmp.path().join("data"));
|
||||
assert!(reloaded.approved(&root, "extra", &extra));
|
||||
assert!(!reloaded.approved(&tmp.path().join("other"), "extra", &extra));
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,16 @@
|
||||
//! Build › Build Project (compiling with the project's build system).
|
||||
mod workspace;
|
||||
|
||||
use super::dependencies::{MavenCentralArtifacts, MavenCentralGroups, MavenCentralVersions};
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
use crate::{Host, Project, apply_workspace_edit};
|
||||
|
||||
use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult};
|
||||
use ide_build::maven::PomGoto;
|
||||
use ide_build::maven::completion::{PomCompletion, PomCompletionOption};
|
||||
use ide_build::pom_edit::{ArtifactKey, Exclusion};
|
||||
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
|
||||
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
|
||||
use std::path::Path;
|
||||
|
||||
fn join_err(e: tokio::task::JoinError) -> IdeError {
|
||||
@@ -313,11 +311,10 @@ pub async fn build_sync(
|
||||
if tool == BuildTool::Gradle {
|
||||
// The sync may report project folders outside the project (`includeFlat`): index and list them.
|
||||
project.refresh_index_scope();
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
let p = project.clone();
|
||||
tokio::task::spawn_blocking(move || p.refresh_repositories());
|
||||
}
|
||||
// New source folders and dependency versions: the Project view and External Libraries reload.
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
if tool == BuildTool::Maven
|
||||
&& model.maven.is_some()
|
||||
&& let Some(bridge) = &project.kotlin_bridge
|
||||
@@ -456,8 +453,7 @@ pub async fn maven_dependency_declaration(
|
||||
/// absolute for a parent POM opened from the local repository): the definition of the
|
||||
/// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>`
|
||||
/// entry providing the dependency's / plugin's version, along the parent chain (into
|
||||
/// the local repository when a parent is not in the project), else the artifact's own
|
||||
/// POM (a project module's, or the local repository's). `None` when nothing
|
||||
/// the local repository when a parent is not in the project). `None` when nothing
|
||||
/// resolvable is under the caret.
|
||||
pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> {
|
||||
let project = host.project(window)?;
|
||||
@@ -468,107 +464,12 @@ pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos:
|
||||
let settings = project.settings.effective();
|
||||
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
|
||||
.or_else(ide_build::maven::default_local_repo);
|
||||
let modules = || {
|
||||
let model = load(&project.root, &project.build_cache_dir(), &settings).maven.unwrap_or_default();
|
||||
model
|
||||
.projects
|
||||
.into_iter()
|
||||
.map(|p| ide_build::maven::ModulePom {
|
||||
group_id: p.group_id,
|
||||
artifact_id: p.artifact_id,
|
||||
pom: project.root.join(&p.pom),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
Ok(ide_build::maven::goto_declaration_in(&text, &path, pos, repo, modules))
|
||||
Ok(ide_build::maven::goto_declaration(&text, &path, pos, repo))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)?
|
||||
}
|
||||
|
||||
/// How long completion waits for Maven Central before answering from the local repository.
|
||||
const CENTRAL_WAIT: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
/// Completion of the `<dependency>` coordinate the caret (`offset`, UTF-16) sits
|
||||
/// in the value of in the POM `doc`: the coordinates installed in the local
|
||||
/// repository (Settings › Maven's, else `~/.m2/repository`) — group ids,
|
||||
/// the chosen group's artifacts, the chosen artifact's versions — topped up
|
||||
/// with Maven Central's (the search endpoint's popular coordinates, the
|
||||
/// published version metadata), which the shared version cache keeps for the
|
||||
/// session. `None` when the caret is not in a `<dependency>` coordinate element.
|
||||
pub async fn maven_dependency_completion(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
offset: u32,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Option<PomCompletion>> {
|
||||
use ide_build::maven::completion::{PomCompletionKind, local_options};
|
||||
let project = host.project(window)?;
|
||||
let cache = host.dependency_versions.clone();
|
||||
let cancel = host.begin_project_read_request(window, request);
|
||||
let result = host
|
||||
.blocking(move || {
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
if snap.large || snap.read_only || !snap.path.file_name().is_some_and(|name| name == "pom.xml") {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(target) = ide_build::maven::completion::completion_target(&snap.text, offset) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let settings = project.settings.effective();
|
||||
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
|
||||
.or_else(ide_build::maven::default_local_repo);
|
||||
let mut options = repo.map_or_else(Vec::new, |repo| local_options(&repo, &target));
|
||||
// Central's answers are cached for the session (the shared version cache).
|
||||
// The local repository answers at once: Central gets `CENTRAL_WAIT`, then the
|
||||
// lookup finishes in the background and fills the cache for the next request.
|
||||
// Offline, behind a proxy or rate-limited, a failed fetch never fails completion.
|
||||
let remote = {
|
||||
let (cache, target) = (cache.clone(), target.clone());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let detached = ide_core::CancellationToken::new();
|
||||
let found = match target.kind {
|
||||
PomCompletionKind::GroupId if !target.typed.is_empty() => {
|
||||
cache.versions(&MavenCentralGroups, &target.typed, &detached).unwrap_or_default()
|
||||
}
|
||||
PomCompletionKind::ArtifactId => match &target.group {
|
||||
Some(group) => cache
|
||||
.versions(&MavenCentralArtifacts { group: group.clone() }, &target.typed, &detached)
|
||||
.unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
},
|
||||
PomCompletionKind::Version => match (&target.group, &target.artifact) {
|
||||
(Some(group), Some(artifact)) => cache
|
||||
.versions(&MavenCentralVersions, &format!("{group}:{artifact}"), &detached)
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
_ => Vec::new(), // A group id from nothing: the search endpoint has no such query.
|
||||
};
|
||||
let _ = tx.send(found);
|
||||
});
|
||||
rx.recv_timeout(CENTRAL_WAIT).unwrap_or_default()
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let known: std::collections::HashSet<String> = options.iter().map(|o| o.label.clone()).collect();
|
||||
options.extend(
|
||||
remote
|
||||
.into_iter()
|
||||
.filter(|label| !known.contains(label))
|
||||
.map(|label| PomCompletionOption { label, detail: None }),
|
||||
);
|
||||
options.truncate(ide_build::maven::completion::MAX_OPTIONS);
|
||||
Ok(Some(PomCompletion { from: target.from, to: target.to, kind: target.kind, options }))
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Adds an `<exclusion>` of `exclusion` to the declaration of the direct dependency
|
||||
/// `dependency` of the Maven module `pom`, through the open document when there is one
|
||||
/// (saved). Returns the declaration; nothing changes when it already excludes it.
|
||||
@@ -810,9 +711,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("maven_goto_declaration", |c| async move {
|
||||
maven_goto_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add("maven_dependency_completion", |c| async move {
|
||||
maven_dependency_completion(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("maven_exclude_dependency", |c| async move {
|
||||
maven_exclude_dependency(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?, c.arg("exclusion")?).await
|
||||
});
|
||||
@@ -985,38 +883,6 @@ mod build_tool_settings_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Changing Project JDK updates the next Maven build without reopening the project.
|
||||
#[test]
|
||||
fn changing_project_jdk_updates_the_next_maven_build() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(
|
||||
root.join("pom.xml"),
|
||||
r#"<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>app</artifactId><version>1</version></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
for (name, version) in [("jdk8", "1.8"), ("jdk21", "21")] {
|
||||
let home = root.join(name);
|
||||
std::fs::create_dir_all(home.join("bin")).unwrap();
|
||||
std::fs::write(home.join("bin").join(if cfg!(windows) { "java.exe" } else { "java" }), "").unwrap();
|
||||
std::fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
|
||||
}
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.maven.runner_jre.clear();
|
||||
for name in ["jdk8", "jdk21"] {
|
||||
settings.languages.java.jdk = name.into();
|
||||
let expected = root.join(name);
|
||||
let model = load(&root, &root.join("cache"), &settings);
|
||||
assert_eq!(model.maven.as_ref().unwrap().projects[0].env["JAVA_HOME"], expected.to_string_lossy());
|
||||
let plan = compile::plan(&root, &model, BuildKind::Build, None, None).unwrap();
|
||||
assert!(!plan.steps.is_empty());
|
||||
for step in plan.steps {
|
||||
assert_eq!(step.env["JAVA_HOME"], expected.to_string_lossy());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1043,120 +909,3 @@ mod build_tool_settings_tests {
|
||||
assert_eq!(model.env["GRADLE_USER_HOME"], root.join("user-home").to_string_lossy());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod maven_completion_tests {
|
||||
use super::*;
|
||||
use crate::ops::dependencies::hashed_key;
|
||||
use ide_build::maven::completion::PomCompletionKind;
|
||||
use ide_core::CancellationToken;
|
||||
use ide_workspace::dependencies::DependencyVersionSource;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Cache answers the test plants before completing, so the op's Central
|
||||
/// lookups hit the session cache and the network is never reached.
|
||||
struct FakeSource(&'static str, Vec<&'static str>);
|
||||
impl DependencyVersionSource for FakeSource {
|
||||
fn id(&self) -> &'static str {
|
||||
self.0
|
||||
}
|
||||
fn cache_key(&self, package: &str) -> Option<String> {
|
||||
Some(hashed_key(self.0, package))
|
||||
}
|
||||
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
|
||||
Ok(self.1.iter().map(|s| (*s).to_owned()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
const GROUPS: &str = "maven-central-groups";
|
||||
const ARTIFACTS: &str = "maven-central-artifacts";
|
||||
const VERSIONS: &str = "maven-central-versions";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn completes_dependency_coordinates_from_the_local_repository_then_central() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let (root, repo) = (base.join("project"), base.join("repository"));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
// Settings › Maven's local repository points at the fixture, so the
|
||||
// test machine's own ~/.m2 is never read.
|
||||
std::fs::create_dir_all(base.join("config")).unwrap();
|
||||
std::fs::write(
|
||||
base.join("config/settings.toml"),
|
||||
format!("[languages.java.maven]\nlocalRepository = \"{}\"\n", repo.to_string_lossy().replace('\\', "/")),
|
||||
)
|
||||
.unwrap();
|
||||
for file in [
|
||||
"org/apache/maven/maven-core/4.0.0-rc-5/maven-core-4.0.0-rc-5.jar",
|
||||
"org/example/lib/2.0/lib-2.0.jar",
|
||||
"org/lib/1.0/lib-1.0.jar",
|
||||
] {
|
||||
let path = repo.join(file);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
}
|
||||
let text = "<project>\n <dependencies>\n <dependency>\n <groupId>org</groupId>\n <artifactId>lib</artifactId>\n <version>1.0</version>\n </dependency>\n <dependency>\n <groupId>org.example</groupId>\n <artifactId></artifactId>\n </dependency>\n <dependency>\n <groupId>org.example</groupId>\n <artifactId>lib</artifactId>\n <version></version>\n </dependency>\n </dependencies>\n</project>\n";
|
||||
let path = root.join("pom.xml");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
|
||||
let host = Host::new(crate::HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
let info = project.documents.open(&path).unwrap();
|
||||
|
||||
let token = CancellationToken::new();
|
||||
host.dependency_versions.versions(&FakeSource(GROUPS, vec!["org.zzz"]), "org", &token).unwrap();
|
||||
host.dependency_versions.versions(&FakeSource(ARTIFACTS, vec!["api"]), "org.example\u{1}", &token).unwrap();
|
||||
host.dependency_versions.versions(&FakeSource(VERSIONS, vec!["9.9"]), "org.example:lib", &token).unwrap();
|
||||
|
||||
let at = |needle: &str| (text.find(needle).unwrap() + needle.len()) as u32;
|
||||
let next = std::sync::atomic::AtomicU32::new(500);
|
||||
let complete = |offset: u32| {
|
||||
maven_dependency_completion(
|
||||
&host,
|
||||
"main",
|
||||
info.id,
|
||||
offset,
|
||||
RequestId(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)),
|
||||
)
|
||||
};
|
||||
fn labels(c: &PomCompletion) -> Vec<&str> {
|
||||
c.options.iter().map(|o| o.label.as_str()).collect()
|
||||
}
|
||||
|
||||
let group =
|
||||
complete(text.find("<groupId>org").unwrap() as u32 + "<groupId>org".len() as u32).await.unwrap().unwrap();
|
||||
assert_eq!(group.kind, PomCompletionKind::GroupId);
|
||||
// The installed groups first, Central's cached suggestions behind them.
|
||||
assert_eq!(labels(&group), ["org", "org.apache.maven", "org.example", "org.zzz"]);
|
||||
// Nothing outside a dependency's coordinates.
|
||||
let outside = complete(at("<dependencies>")).await;
|
||||
assert!(matches!(outside, Ok(None)), "{outside:?}");
|
||||
|
||||
let empty_artifact = text.find("<artifactId></artifactId>").unwrap() as u32 + "<artifactId>".len() as u32;
|
||||
let artifact = complete(empty_artifact).await;
|
||||
let artifact = match artifact {
|
||||
Ok(Some(a)) => a,
|
||||
other => panic!("artifact completion: {other:?}"),
|
||||
};
|
||||
assert_eq!(artifact.kind, PomCompletionKind::ArtifactId);
|
||||
let installed = artifact.options.first().unwrap();
|
||||
assert_eq!((installed.label.as_str(), installed.detail.as_deref()), ("lib", Some("2.0")));
|
||||
assert_eq!(labels(&artifact), ["lib", "api"]);
|
||||
|
||||
let empty_version = text.find("<version></version>").unwrap() as u32 + "<version>".len() as u32;
|
||||
let version = complete(empty_version).await.unwrap().unwrap();
|
||||
assert_eq!(version.kind, PomCompletionKind::Version);
|
||||
assert_eq!(labels(&version), ["2.0", "9.9"]);
|
||||
host.close_project("main");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Repository version lookups for manifest inspections and completion.
|
||||
use crate::{Host, rpc::Dispatcher};
|
||||
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
|
||||
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go, maven, npm};
|
||||
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go, npm};
|
||||
use std::collections::BTreeMap;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -25,79 +25,6 @@ impl DependencyVersionSource for CratesIo {
|
||||
}
|
||||
}
|
||||
|
||||
/// A cache key for a `package` that is not a filesystem-safe name (a Maven
|
||||
/// coordinate with dots, a query): hashed under the source's id.
|
||||
pub(crate) fn hashed_key(id: &str, package: &str) -> String {
|
||||
let mut hash = DefaultHasher::new();
|
||||
package.hash(&mut hash);
|
||||
format!("{id}-{:016x}", hash.finish())
|
||||
}
|
||||
|
||||
/// Maven Central's group-id suggestions: the search endpoint's groups matching
|
||||
/// a typed prefix, most recently updated first.
|
||||
pub(crate) struct MavenCentralGroups;
|
||||
impl DependencyVersionSource for MavenCentralGroups {
|
||||
fn id(&self) -> &'static str {
|
||||
"maven-central-groups"
|
||||
}
|
||||
|
||||
fn cache_key(&self, prefix: &str) -> Option<String> {
|
||||
Some(hashed_key(self.id(), prefix))
|
||||
}
|
||||
|
||||
fn fetch(&self, prefix: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
|
||||
let url = maven::groups_url(prefix).ok_or_else(|| IdeError::internal("invalid group prefix"))?;
|
||||
let search_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let text =
|
||||
ide_tools::Http::new(&search_path)?.https_only().get_text_limited(&url, Some(1024 * 1024), cancel)?;
|
||||
maven::parse_search_groups(&text).ok_or_else(|| IdeError::internal("invalid search response"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The artifacts of one group matching a typed prefix, from the same endpoint.
|
||||
pub(crate) struct MavenCentralArtifacts {
|
||||
pub group: String,
|
||||
}
|
||||
impl DependencyVersionSource for MavenCentralArtifacts {
|
||||
fn id(&self) -> &'static str {
|
||||
"maven-central-artifacts"
|
||||
}
|
||||
|
||||
fn cache_key(&self, prefix: &str) -> Option<String> {
|
||||
Some(hashed_key(self.id(), &format!("{}\u{1}{prefix}", self.group)))
|
||||
}
|
||||
|
||||
fn fetch(&self, prefix: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
|
||||
let url =
|
||||
maven::artifacts_url(&self.group, prefix).ok_or_else(|| IdeError::internal("invalid artifact prefix"))?;
|
||||
let search_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let text =
|
||||
ide_tools::Http::new(&search_path)?.https_only().get_text_limited(&url, Some(1024 * 1024), cancel)?;
|
||||
maven::parse_search_artifacts(&text).ok_or_else(|| IdeError::internal("invalid search response"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every version published of one `group:artifact` on Central, newest first.
|
||||
pub(crate) struct MavenCentralVersions;
|
||||
impl DependencyVersionSource for MavenCentralVersions {
|
||||
fn id(&self) -> &'static str {
|
||||
"maven-central-versions"
|
||||
}
|
||||
|
||||
fn cache_key(&self, coordinates: &str) -> Option<String> {
|
||||
Some(hashed_key(self.id(), coordinates))
|
||||
}
|
||||
|
||||
fn fetch(&self, coordinates: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
|
||||
let (group, artifact) = coordinates.split_once(':').ok_or_else(|| IdeError::internal("invalid coordinates"))?;
|
||||
let url = maven::metadata_url(group, artifact).ok_or_else(|| IdeError::internal("invalid coordinates"))?;
|
||||
let search_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let text =
|
||||
ide_tools::Http::new(&search_path)?.https_only().get_text_limited(&url, Some(2 * 1024 * 1024), cancel)?;
|
||||
Ok(maven::parse_metadata_versions(&text))
|
||||
}
|
||||
}
|
||||
|
||||
struct NpmRegistry;
|
||||
impl DependencyVersionSource for NpmRegistry {
|
||||
fn id(&self) -> &'static str {
|
||||
|
||||
@@ -114,28 +114,14 @@ pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
if empty && !bom.is_empty() {
|
||||
std::fs::write(&path, bom)?;
|
||||
}
|
||||
// A checkout backend opens the file for add; Git's hook is a no-op.
|
||||
if let Err(e) = p.with_backend_of(&path, |r| r.files_added(std::slice::from_ref(&path))) {
|
||||
tracing::debug!("vcs files_added for {}: {e}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => ide_vfs::create_file(&path),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_file_bytes(host: &Host, window: &str, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
|
||||
let project = host.project(window).ok();
|
||||
host.blocking(move || {
|
||||
ide_vfs::create_file_bytes(&path, &data)?;
|
||||
if let Some(p) = project
|
||||
&& let Err(e) = p.with_backend_of(&path, |r| r.files_added(std::slice::from_ref(&path)))
|
||||
{
|
||||
tracing::debug!("vcs files_added for {}: {e}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
pub async fn create_file_bytes(host: &Host, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
|
||||
host.blocking(move || ide_vfs::create_file_bytes(&path, &data)).await
|
||||
}
|
||||
|
||||
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
|
||||
@@ -149,54 +135,25 @@ pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) ->
|
||||
|
||||
/// Moves to the trash; Local History keeps the contents so deleted files can be restored.
|
||||
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
let project = host.project(window).ok();
|
||||
let history = project.as_ref().map(|p| p.history.clone());
|
||||
let history = host.project(window).ok().map(|p| p.history.clone());
|
||||
host.blocking(move || {
|
||||
let paths = ide_workspace::file_ops::deletion_paths(&path)?;
|
||||
if let Some(h) = history {
|
||||
for p in &paths {
|
||||
h.record_deletion(p, DELETE_SNAPSHOT_LIMIT);
|
||||
for p in paths {
|
||||
h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT);
|
||||
}
|
||||
}
|
||||
ide_workspace::file_ops::delete(&path)?;
|
||||
// A checkout backend opens the paths for delete; Git's hook is a no-op.
|
||||
if let Some(p) = project {
|
||||
let _ = p.with_backend_of(&path, |r| r.files_deleted(&paths));
|
||||
}
|
||||
Ok(())
|
||||
ide_workspace::file_ops::delete(&path)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn copy(host: &Host, window: &str, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
let project = host.project(window).ok();
|
||||
host.blocking(move || {
|
||||
ide_workspace::file_ops::copy(&from, &to)?;
|
||||
// A copy is an add at the destination; Git's hook is a no-op.
|
||||
if let Some(p) = project
|
||||
&& let Err(e) = p.with_backend_of(&to, |r| r.files_added(std::slice::from_ref(&to)))
|
||||
{
|
||||
tracing::debug!("vcs files_added for {}: {e}", to.display());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
host.blocking(move || ide_workspace::file_ops::copy(&from, &to)).await
|
||||
}
|
||||
|
||||
pub async fn rename(host: &Host, window: &str, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
let project = host.project(window).ok();
|
||||
host.blocking(move || {
|
||||
ide_workspace::file_ops::rename(&from, &to)?;
|
||||
// A checkout backend records the rename so history follows the file;
|
||||
// Git's hook is a no-op.
|
||||
if let Some(p) = project
|
||||
&& let Err(e) = p.with_backend_of(&to, |r| r.file_moved(&from, &to))
|
||||
{
|
||||
tracing::debug!("vcs file_moved {} → {}: {e}", from.display(), to.display());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
pub async fn rename(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
host.blocking(move || ide_workspace::file_ops::rename(&from, &to)).await
|
||||
}
|
||||
|
||||
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
|
||||
|
||||
+30
-166
@@ -48,7 +48,7 @@ async fn resolve_launch(
|
||||
let build = policy != BuildPolicy::NoBuild;
|
||||
let full = policy == BuildPolicy::Rebuild;
|
||||
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
|
||||
let (model, external, mut delegated, prefer_maven, is_maven) = {
|
||||
let (model, external, delegated, prefer_maven) = {
|
||||
let project = project.clone();
|
||||
let settings = settings.clone();
|
||||
let module = module.clone();
|
||||
@@ -68,47 +68,30 @@ async fn resolve_launch(
|
||||
prefer_maven,
|
||||
);
|
||||
let external = non_java || delegated;
|
||||
(model, external, delegated, prefer_maven, is_maven)
|
||||
(model, external, delegated, prefer_maven)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
};
|
||||
let mut external_build =
|
||||
if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
|
||||
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
|
||||
let mut info = project.lsp.java_launch(&module, test, build && !delegated, full, delegated, cancel).await?;
|
||||
if info.errors.iter().any(|e| processor_recreated_a_file(&e.message)) {
|
||||
if build && !delegated && is_maven {
|
||||
// JDT runs an annotation processor again over sources it generated in the same build
|
||||
// (MapStruct over MapStruct-Plus's mappers) and fails on the file it already wrote;
|
||||
// javac doesn't, so Maven builds the module.
|
||||
tracing::info!("Annotation processing failed in JDT; building with Maven");
|
||||
delegated = true;
|
||||
external_build = Some(project.lsp.java_external_build(&module, cancel).await?);
|
||||
info = project.lsp.java_launch(&module, test, false, false, true, cancel).await?;
|
||||
}
|
||||
if delegated {
|
||||
// Maven runs the annotation processors itself; JDT's failure to rerun one is no compile error.
|
||||
drop_processor_recreate_errors(&mut info);
|
||||
}
|
||||
}
|
||||
let lsp_ms = started.elapsed().as_millis();
|
||||
let data_dir = host.local_data_dir.clone();
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _external_build = external_build;
|
||||
let local_repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
|
||||
let built = if delegated {
|
||||
let rebuild_on_jdt_errors = rebuild_on_jdt_errors(policy, prefer_maven, info.error_count);
|
||||
let policy = delegated_policy(policy, prefer_maven, info.error_count);
|
||||
let outputs = project.java_launch_snapshots.lock().build(
|
||||
&project.root,
|
||||
&model,
|
||||
&module,
|
||||
cache::BuildOptions { test, policy, rebuild_on_jdt_errors },
|
||||
local_repo.clone(),
|
||||
cache::BuildOptions { test, policy },
|
||||
ide_sdk::apply::maven_local_repository(&project.root, &settings),
|
||||
&cancel,
|
||||
)?;
|
||||
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
|
||||
apply_external_build_result(&mut info, outputs.failure, outputs.authoritative);
|
||||
apply_external_build_result(&mut info, outputs.failure, policy == BuildPolicy::Rebuild);
|
||||
copies
|
||||
} else if external {
|
||||
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
|
||||
@@ -124,22 +107,10 @@ async fn resolve_launch(
|
||||
Vec::new()
|
||||
};
|
||||
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
if delegated {
|
||||
// m2e runs inside jdtls on the server JDK and can resolve a different
|
||||
// Maven profile/classifier than the module build. Ask the same Maven
|
||||
// process that built the module for its runtime/test dependencies.
|
||||
let maven_cp = compile::module_classpath(
|
||||
&project.root,
|
||||
&model,
|
||||
&module,
|
||||
test,
|
||||
local_repo,
|
||||
&project.build_cache_dir(),
|
||||
&cancel,
|
||||
);
|
||||
merge_maven_launch_classpath(&mut class_paths, &mut info, maven_cp)?;
|
||||
} else {
|
||||
append_unique_class_paths(&mut class_paths, std::mem::take(&mut info.class_paths));
|
||||
for p in std::mem::take(&mut info.class_paths) {
|
||||
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
|
||||
class_paths.push(p);
|
||||
}
|
||||
}
|
||||
info.class_paths = class_paths;
|
||||
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
|
||||
@@ -204,18 +175,21 @@ async fn resolve_launch(
|
||||
/// With Settings › Maven › Delegate IDE build/run actions to Maven, javac's verdict replaces
|
||||
/// JDT's. Classes JDT wrote for sources with errors look up to date to Maven's incremental
|
||||
/// compiler, so a launch JDT reports errors for recompiles everything.
|
||||
fn rebuild_on_jdt_errors(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> bool {
|
||||
prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make)
|
||||
fn delegated_policy(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> BuildPolicy {
|
||||
if prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make) {
|
||||
BuildPolicy::Rebuild
|
||||
} else {
|
||||
policy
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, authoritative: bool) {
|
||||
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, clean: bool) {
|
||||
info.build = Some(if let Some(failure) = failure {
|
||||
info.hints.push(failure);
|
||||
JavaBuildStatus::Failed
|
||||
} else if authoritative {
|
||||
// A clean rebuild or a fingerprint-verified snapshot is authoritative. JDT diagnostics
|
||||
// may describe an older output or its own classpath, so they must not mark this launch
|
||||
// as failed after Maven has produced usable classes.
|
||||
} else if clean {
|
||||
// A clean external build recompiled every source; JDT's diagnostics still describe
|
||||
// the previous output (and reject sources javac accepts, e.g. a package mismatch).
|
||||
info.errors.clear();
|
||||
info.error_count = 0;
|
||||
JavaBuildStatus::Succeeded
|
||||
@@ -296,44 +270,6 @@ fn same_entry(a: &str, b: &str, windows: bool) -> bool {
|
||||
a == b || entry_key(a, windows) == entry_key(b, windows)
|
||||
}
|
||||
|
||||
/// Merge the Maven launch classpath without turning a recoverable probe failure into a
|
||||
/// failed launch. Cancellation still propagates; ordinary failures keep jdtls' classpath
|
||||
/// and leave the reason in the run console.
|
||||
fn merge_maven_launch_classpath(
|
||||
class_paths: &mut Vec<String>,
|
||||
info: &mut JavaLaunchInfo,
|
||||
classpath: IdeResult<String>,
|
||||
) -> IdeResult<()> {
|
||||
match classpath {
|
||||
Ok(classpath) => {
|
||||
append_unique_class_paths(
|
||||
class_paths,
|
||||
std::env::split_paths(&classpath).map(|path| path.to_string_lossy().into_owned()),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) if error.kind == ErrorKind::Cancelled => Err(error),
|
||||
Err(error) => {
|
||||
info.hints.push(format!(
|
||||
"Maven launch classpath resolution failed; using the Java language server classpath instead:\n{}",
|
||||
error.message
|
||||
));
|
||||
append_unique_class_paths(class_paths, std::mem::take(&mut info.class_paths));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append entries without changing their precedence; `same_entry` also catches
|
||||
/// Windows case-insensitive duplicates that would otherwise lengthen the command.
|
||||
fn append_unique_class_paths(class_paths: &mut Vec<String>, entries: impl IntoIterator<Item = String>) {
|
||||
for entry in entries {
|
||||
if !class_paths.iter().any(|existing| same_entry(existing, &entry, cfg!(windows))) {
|
||||
class_paths.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
|
||||
use_snapshot_on(info, built, snapshot, cfg!(windows))
|
||||
}
|
||||
@@ -402,92 +338,20 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
});
|
||||
}
|
||||
|
||||
/// An annotation processor's error for writing a file this build already wrote (JDT's
|
||||
/// `IdeFilerImpl`: "Source file already created", javac's Filer: "Attempt to recreate a file").
|
||||
fn processor_recreated_a_file(message: &str) -> bool {
|
||||
message.contains("FilerException")
|
||||
&& (message.contains("Source file already created") || message.contains("Attempt to recreate a file"))
|
||||
}
|
||||
|
||||
fn drop_processor_recreate_errors(info: &mut JavaLaunchInfo) {
|
||||
let before = info.errors.len();
|
||||
info.errors.retain(|e| !processor_recreated_a_file(&e.message));
|
||||
info.error_count = info.error_count.saturating_sub((before - info.errors.len()) as u32);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recognises_a_processor_recreating_its_output() {
|
||||
assert!(processor_recreated_a_file(
|
||||
"Internal error in the mapping processor: java.lang.RuntimeException: \
|
||||
javax.annotation.processing.FilerException: Source file already created: /m/target/generated-sources/annotations/a/AMapper__4Impl.java"
|
||||
));
|
||||
assert!(processor_recreated_a_file(
|
||||
"javax.annotation.processing.FilerException: Attempt to recreate a file for type a.B"
|
||||
));
|
||||
assert!(!processor_recreated_a_file("Source file already created"));
|
||||
assert!(!processor_recreated_a_file("cannot find symbol"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delegated_launch_ignores_jdt_processor_recreate_errors() {
|
||||
let error = |message: &str| ide_lsp::jdtls::JavaCompileError {
|
||||
path: "src/main/java/a/A.java".into(),
|
||||
line: 1,
|
||||
column: 1,
|
||||
message: message.into(),
|
||||
};
|
||||
let mut info = JavaLaunchInfo {
|
||||
error_count: 2,
|
||||
errors: vec![
|
||||
error(
|
||||
"javax.annotation.processing.FilerException: Source file already created: /m/AMapper__1Impl.java",
|
||||
),
|
||||
error("cannot find symbol"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
drop_processor_recreate_errors(&mut info);
|
||||
assert_eq!(info.error_count, 1);
|
||||
assert_eq!(info.errors.len(), 1);
|
||||
assert_eq!(info.errors[0].message, "cannot find symbol");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
|
||||
assert!(rebuild_on_jdt_errors(BuildPolicy::Make, true, 1));
|
||||
assert!(rebuild_on_jdt_errors(BuildPolicy::Automatic, true, 2));
|
||||
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, true, 0));
|
||||
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, false, 1));
|
||||
assert!(!rebuild_on_jdt_errors(BuildPolicy::MakeAndContinueOnErrors, true, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_classpath_probe_failure_falls_back_to_jdtls_classpaths() {
|
||||
let maven = std::env::join_paths(["maven.jar", "snapshot.jar"]).unwrap().to_string_lossy().into_owned();
|
||||
let mut class_paths = vec!["snapshot.jar".to_owned()];
|
||||
let mut info =
|
||||
JavaLaunchInfo { class_paths: vec!["snapshot.jar".into(), "jdtls.jar".into()], ..Default::default() };
|
||||
merge_maven_launch_classpath(&mut class_paths, &mut info, Ok(maven)).unwrap();
|
||||
assert_eq!(class_paths, ["snapshot.jar", "maven.jar"]);
|
||||
|
||||
merge_maven_launch_classpath(&mut class_paths, &mut info, Err(IdeError::internal("offline dependency")))
|
||||
.unwrap();
|
||||
assert_eq!(class_paths, ["snapshot.jar", "maven.jar", "jdtls.jar"]);
|
||||
assert!(info.hints[0].contains("offline dependency"), "{:?}", info.hints);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_maven_classpath_probe_propagates_without_fallback() {
|
||||
let mut class_paths = vec!["snapshot.jar".to_owned()];
|
||||
let mut info = JavaLaunchInfo { class_paths: vec!["jdtls.jar".into()], ..Default::default() };
|
||||
let error = merge_maven_launch_classpath(&mut class_paths, &mut info, Err(IdeError::cancelled())).unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::Cancelled);
|
||||
assert_eq!(class_paths, ["snapshot.jar"]);
|
||||
assert!(info.hints.is_empty());
|
||||
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
|
||||
assert_eq!(delegated_policy(BuildPolicy::Automatic, true, 2), BuildPolicy::Rebuild);
|
||||
assert_eq!(delegated_policy(BuildPolicy::Make, true, 0), BuildPolicy::Make);
|
||||
assert_eq!(delegated_policy(BuildPolicy::Make, false, 1), BuildPolicy::Make);
|
||||
assert_eq!(
|
||||
delegated_policy(BuildPolicy::MakeAndContinueOnErrors, true, 1),
|
||||
BuildPolicy::MakeAndContinueOnErrors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -531,7 +395,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_external_result_clears_stale_jdt_errors() {
|
||||
fn clean_external_build_clears_stale_jdt_errors() {
|
||||
let mut info = JavaLaunchInfo {
|
||||
error_count: 7,
|
||||
errors: vec![ide_lsp::jdtls::JavaCompileError {
|
||||
|
||||
@@ -14,10 +14,6 @@ pub(super) struct Outputs {
|
||||
pub originals: Vec<PathBuf>,
|
||||
#[serde(skip)]
|
||||
pub failure: Option<String>,
|
||||
/// Whether this result replaces jdtls diagnostics with a clean Maven build or a
|
||||
/// verified snapshot. It is runtime-only because reuse is determined per launch.
|
||||
#[serde(skip)]
|
||||
pub authoritative: bool,
|
||||
}
|
||||
|
||||
impl Outputs {
|
||||
@@ -46,9 +42,6 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
|
||||
pub(super) struct BuildOptions {
|
||||
pub test: bool,
|
||||
pub policy: BuildPolicy,
|
||||
/// A JDT error makes a cache miss rebuild, but it must not prevent an unchanged
|
||||
/// snapshot from being reused on the next launch.
|
||||
pub rebuild_on_jdt_errors: bool,
|
||||
}
|
||||
|
||||
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
|
||||
@@ -82,7 +75,7 @@ impl Session {
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Outputs> {
|
||||
self.initialize();
|
||||
let BuildOptions { test, policy, rebuild_on_jdt_errors } = options;
|
||||
let BuildOptions { test, policy } = options;
|
||||
let cache = &self.cache;
|
||||
let record =
|
||||
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
|
||||
@@ -122,14 +115,8 @@ impl Session {
|
||||
&& outputs.reusable(&before, &snapshots)
|
||||
{
|
||||
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
|
||||
let mut outputs = previous.unwrap();
|
||||
outputs.authoritative = true;
|
||||
return Ok(outputs);
|
||||
return Ok(previous.unwrap());
|
||||
}
|
||||
// JDT can report errors from its own classpath while the last Maven snapshot is
|
||||
// still valid. Reuse that snapshot first; only a cache miss needs the expensive
|
||||
// clean rebuild that replaces JDT's error-stub classes.
|
||||
let policy = if rebuild_on_jdt_errors { BuildPolicy::Rebuild } else { policy };
|
||||
// Retain a non-reusable record even on failure, so removing the final mixed
|
||||
// source still delegates to Maven to clean its old classes on the next launch.
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
@@ -169,13 +156,7 @@ impl Session {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut outputs = Outputs {
|
||||
fingerprint: None,
|
||||
snapshot,
|
||||
originals,
|
||||
failure: built.failure,
|
||||
authoritative: policy == BuildPolicy::Rebuild,
|
||||
};
|
||||
let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure };
|
||||
if outputs.failure.is_none() && before.is_some() && before == after {
|
||||
outputs.fingerprint = before;
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
@@ -353,48 +334,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdt_errors_reuse_an_unchanged_snapshot_and_rebuild_only_on_a_cache_miss() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(root.join("cache"));
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert!(!first.authoritative);
|
||||
|
||||
let reused = session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { rebuild_on_jdt_errors: true, ..Default::default() },
|
||||
None,
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reused.snapshot, first.snapshot);
|
||||
assert!(reused.authoritative);
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1);
|
||||
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
|
||||
let rebuilt = session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { rebuild_on_jdt_errors: true, ..Default::default() },
|
||||
None,
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(rebuilt.snapshot, first.snapshot);
|
||||
assert!(rebuilt.authoritative);
|
||||
let calls = fs::read_to_string(root.join("calls")).unwrap();
|
||||
assert_eq!(calls.lines().count(), 2);
|
||||
assert!(calls.lines().last().unwrap().contains("clean"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -544,13 +483,7 @@ mod tests {
|
||||
fs::write(snapshots.join("launch-file"), "keep").unwrap();
|
||||
let record = cache.join("java-launch/records/deep/module/test.json");
|
||||
fs::create_dir_all(record.parent().unwrap()).unwrap();
|
||||
let outputs = Outputs {
|
||||
fingerprint: None,
|
||||
snapshot: retained.clone(),
|
||||
originals: Vec::new(),
|
||||
failure: None,
|
||||
authoritative: false,
|
||||
};
|
||||
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), failure: None };
|
||||
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
|
||||
let mut session = Session::new(cache);
|
||||
session.initialize();
|
||||
|
||||
@@ -1483,38 +1483,23 @@ pub async fn lsp_restart_named(host: &Host, name: &str) -> IdeResult<()> {
|
||||
pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
record_before_multi_file_edit(&project, &edit);
|
||||
// Multi-file refactorings leave a Local History point to go back to.
|
||||
let paths: Vec<&std::path::Path> = edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if edit.files.len() > 1 {
|
||||
project.record_before_refactoring(&paths, "Apply changes");
|
||||
}
|
||||
apply_edit(&project.documents, &edit)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`apply_workspace_edit`] for the MCP server's tools: undoable the same way, but the UI does
|
||||
/// not announce it as a refactoring.
|
||||
pub async fn apply_agent_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
record_before_multi_file_edit(&project, &edit);
|
||||
project.documents.apply_agent_workspace_edit(&edit)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Multi-file edits leave a Local History point to go back to.
|
||||
fn record_before_multi_file_edit(project: &crate::Project, edit: &WorkspaceEdit) {
|
||||
let paths: Vec<&std::path::Path> = edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if edit.files.len() > 1 {
|
||||
project.record_before_refactoring(&paths, "Apply changes");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult<WorkspaceEdit> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
|
||||
@@ -234,19 +234,7 @@ pub async fn start_request(
|
||||
) -> IdeResult<RunInfo> {
|
||||
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
|
||||
let operation = config.extras.params.get("plugin.operation").cloned();
|
||||
let mut config = config;
|
||||
let agent = match host.project(window) {
|
||||
Ok(project) => host.mcp_agent_runs.launch(&project.root, window, &mut config),
|
||||
Err(_) => None,
|
||||
};
|
||||
let on_output = match &agent {
|
||||
Some(agent) => agent.wrap_output(on_output),
|
||||
None => on_output,
|
||||
};
|
||||
let result = start_inner(host, window, config, on_output, agent.as_ref(), &cancel).await;
|
||||
if let Some(agent) = &agent {
|
||||
agent.started(&result);
|
||||
}
|
||||
let result = start_inner(host, window, config, on_output, &cancel).await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
@@ -261,7 +249,6 @@ async fn start_inner(
|
||||
window: &str,
|
||||
mut config: RunConfig,
|
||||
on_output: Emit<OutputChunk>,
|
||||
agent: Option<&crate::mcp::run::agent::Launch>,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<RunInfo> {
|
||||
if cancel.is_cancelled() {
|
||||
@@ -295,9 +282,6 @@ async fn start_inner(
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
sink = plugins.run_operation_sink(window, &mut config, sink)?;
|
||||
}
|
||||
if let Some(agent) = agent {
|
||||
sink = agent.wrap_sink(sink);
|
||||
}
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
@@ -466,7 +450,6 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
plugins.run_operation_stopped(id);
|
||||
}
|
||||
host.mcp_agent_runs.stopping(id);
|
||||
let settings = match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
@@ -478,9 +461,7 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
host.processes.stop_with_grace(id, grace)
|
||||
}
|
||||
|
||||
/// Input the user typed into a run (the agent's goes to the process directly).
|
||||
pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> {
|
||||
host.mcp_agent_runs.user_input(id, data);
|
||||
if host.remote.runs.contains(id) {
|
||||
return host.remote.runs.write_stdin(id, data);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use ide_sdk::python::PythonPackage;
|
||||
use ide_sdk::{Sdk, SdkKind};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn join_err(e: impl std::fmt::Display) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
@@ -22,14 +20,6 @@ pub struct LocatedTools {
|
||||
pub compile_dir: ide_core::IpcPath,
|
||||
}
|
||||
|
||||
/// An interpreter another project uses: the project root and the SDK itself.
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectInterpreter {
|
||||
pub project: ide_core::IpcPath,
|
||||
pub sdk: Sdk,
|
||||
}
|
||||
|
||||
/// Installed interpreters / toolchains of `kind`; project-local ones first.
|
||||
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
|
||||
host.require_trust(window)?;
|
||||
@@ -55,53 +45,6 @@ pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<V
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The Python interpreters of the projects opened recently (newest first), each with the
|
||||
/// project it belongs to. The open project is skipped: [`sdk_detect`] lists its own.
|
||||
pub async fn sdk_python_project_interpreters(host: &Host, window: &str) -> IdeResult<Vec<ProjectInterpreter>> {
|
||||
host.require_trust(window)?;
|
||||
let current = host.project(window).ok().map(|p| p.root.clone());
|
||||
let config = host.config_dir.clone();
|
||||
// Probing runs the project's interpreter (and its settings may name any program): only
|
||||
// projects trusted in their own right, not ones last opened in Restricted Mode.
|
||||
let roots: Vec<PathBuf> = ide_workspace::RecentProjects::load(&config)
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|p| p.path.into_path_buf())
|
||||
.filter(|root| current.as_deref() != Some(root.as_path()))
|
||||
.filter(|root| host.trust.window_trusted(root).unwrap_or(false))
|
||||
.collect();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Each project resolves its configured interpreter, else its own virtualenv. The
|
||||
// probes run `python`, so keep them off the async runtime and bound how many run at
|
||||
// once: the recent list can hold ~30 projects, and each probe starts a process.
|
||||
const PROBES: usize = 4;
|
||||
let results = Mutex::new(Vec::with_capacity(roots.len()));
|
||||
let next = AtomicUsize::new(0);
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..roots.len().min(PROBES) {
|
||||
scope.spawn(|| {
|
||||
loop {
|
||||
let index = next.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(root) = roots.get(index) else { break };
|
||||
let settings = ide_workspace::SettingsStore::load(&config, Some(root)).effective();
|
||||
let Some(python) = ide_sdk::apply::python_interpreter(root, &settings) else { continue };
|
||||
let Some(sdk) = ide_sdk::python_interpreter_sdk(&python, Some(root)) else { continue };
|
||||
results
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((index, ProjectInterpreter { project: root.as_path().into(), sdk }));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let mut found = results.into_inner().unwrap();
|
||||
found.sort_by_key(|(index, _)| *index);
|
||||
found.into_iter().map(|(_, interpreter)| interpreter).collect()
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// An existing Python executable or virtualenv folder as an SDK; `None` if Python cannot run.
|
||||
pub async fn sdk_python_interpreter(host: &Host, window: &str, path: PathBuf) -> IdeResult<Option<Sdk>> {
|
||||
host.require_trust(window)?;
|
||||
@@ -367,10 +310,6 @@ pub async fn sdk_python_pip(
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
|
||||
d.add(
|
||||
"sdk_python_project_interpreters",
|
||||
|c| async move { sdk_python_project_interpreters(&c.host, &c.window).await },
|
||||
);
|
||||
d.add(
|
||||
"sdk_python_interpreter",
|
||||
|c| async move { sdk_python_interpreter(&c.host, &c.window, c.arg("path")?).await },
|
||||
@@ -486,53 +425,4 @@ mod tests {
|
||||
assert!(sdk_python_interpreter(&host, "main", root.join("README.md")).await.unwrap().is_none());
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn another_projects_interpreter_is_listed_with_its_project() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
let other = base.join("shop");
|
||||
std::fs::create_dir_all(other.join(".openidea")).unwrap();
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let python = fake_venv(&other, ".venv");
|
||||
std::fs::write(
|
||||
other.join(".openidea/settings.toml"),
|
||||
"[languages.python]\ninterpreter = \".venv/bin/python\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
// The projects the user opened before this one.
|
||||
let config = base.join("config");
|
||||
std::fs::create_dir_all(&config).unwrap();
|
||||
let recent = serde_json::json!([
|
||||
{ "path": other.to_str().unwrap(), "name": "shop", "openedAtMs": 2.0, "branch": null },
|
||||
{ "path": root.to_str().unwrap(), "name": "project", "openedAtMs": 1.0, "branch": null },
|
||||
]);
|
||||
std::fs::write(config.join("recent.json"), serde_json::to_vec(&recent).unwrap()).unwrap();
|
||||
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: config,
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
|
||||
// A project last opened in Restricted Mode: its interpreter is never run.
|
||||
host.trust.decide(&other, false, false).unwrap();
|
||||
assert!(sdk_python_project_interpreters(&host, "main").await.unwrap().is_empty());
|
||||
host.trust.decide(&other, true, false).unwrap();
|
||||
let list = sdk_python_project_interpreters(&host, "main").await.unwrap();
|
||||
assert_eq!(list.len(), 1, "{list:?}");
|
||||
assert_eq!(&*list[0].project, other.as_path());
|
||||
assert_eq!(&*list[0].sdk.path, python.as_path());
|
||||
assert_eq!(list[0].sdk.name, "Python 3.12 (shop)");
|
||||
host.close_project("main");
|
||||
}
|
||||
}
|
||||
@@ -138,11 +138,6 @@ impl LogAccess {
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
|
||||
.with_log_repo_at(at, f)
|
||||
}
|
||||
|
||||
/// `with_log_repo_at` through the backend-agnostic Changes-view surface.
|
||||
fn with_log_backend_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_log_repo_at(at, |r| f(r as &dyn Backend))
|
||||
}
|
||||
}
|
||||
|
||||
/// A repository of the project (IDEA's VCS root).
|
||||
@@ -285,16 +280,6 @@ fn per_repo(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `per_repo` through the backend-agnostic Changes-view surface.
|
||||
fn per_backend(
|
||||
project: &Project,
|
||||
paths: &[PathBuf],
|
||||
recorded: Option<&str>,
|
||||
f: impl Fn(&dyn Backend, &[PathBuf]) -> IdeResult<()>,
|
||||
) -> IdeResult<()> {
|
||||
per_repo(project, paths, recorded, |r, paths| f(r as &dyn Backend, paths))
|
||||
}
|
||||
|
||||
/// A shelf is one patch for one owning repository. Validate the whole selection before reverting files.
|
||||
fn shelf_repository(project: &Project, paths: &[PathBuf]) -> IdeResult<PathBuf> {
|
||||
let repos = project.repos();
|
||||
@@ -363,7 +348,7 @@ pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResu
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let text = project.text_of(&path)?;
|
||||
project.with_backend_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
|
||||
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -375,7 +360,7 @@ pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<S
|
||||
});
|
||||
blocking(move || {
|
||||
if let Some(project) = project {
|
||||
return project.with_log_backend_of(&path, |r| {
|
||||
return project.with_log_repo_of(&path, |r| {
|
||||
r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?)
|
||||
});
|
||||
}
|
||||
@@ -398,7 +383,7 @@ pub async fn vcs_file_mode(host: &Host, window: &str, path: PathBuf, rev: Option
|
||||
});
|
||||
blocking(move || {
|
||||
if let Some(project) = project {
|
||||
return project.with_log_backend_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
|
||||
return project.with_log_repo_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
|
||||
}
|
||||
external
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
|
||||
@@ -418,7 +403,7 @@ pub async fn vcs_blame(
|
||||
// An explicit text wins (merge view annotates its side panes, not the worktree).
|
||||
blocking(move || {
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
project.with_backend_of(&path, |r| {
|
||||
project.with_repo_of(&path, |r| {
|
||||
r.blame(&path, text.as_deref(), revision.as_deref(), project.vcs_encoding(&path, revision.as_deref())?)
|
||||
})
|
||||
})
|
||||
@@ -439,7 +424,7 @@ pub async fn vcs_block_history(
|
||||
let cancel = host.begin_request(request);
|
||||
let result = blocking(move || {
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
project.with_backend_of(&path, |r| {
|
||||
project.with_repo_of(&path, |r| {
|
||||
r.block_history(&path, text.as_deref(), lines, project.vcs_encoding(&path, None)?, &cancel)
|
||||
})
|
||||
})
|
||||
@@ -456,7 +441,7 @@ pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: Lo
|
||||
let result = blocking(move || {
|
||||
project.with_log_repo_at(repo.as_deref(), |r| {
|
||||
r.enable_log_index(&project.cache);
|
||||
(r as &dyn Backend).log(&query, &cancel)
|
||||
r.log_cancellable(&query, &cancel)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
@@ -555,7 +540,7 @@ pub async fn vcs_commit_files(
|
||||
commit: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.commit_files(&commit))).await
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
|
||||
}
|
||||
|
||||
/// Combined changes of several commits, `commits` oldest first.
|
||||
@@ -566,7 +551,7 @@ pub async fn vcs_commits_changes(
|
||||
commits: Vec<String>,
|
||||
) -> IdeResult<Vec<RevisionChange>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
|
||||
@@ -582,7 +567,7 @@ pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
|
||||
pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_backend(&project, &paths, None, |r, paths| r.stage(paths))).await
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
|
||||
}
|
||||
|
||||
/// Project-host operation (also registered with the remote dispatcher).
|
||||
@@ -617,7 +602,7 @@ pub async fn vcs_add_to_ignore(
|
||||
pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_backend(&project, &paths, None, |r, paths| r.unstage(paths))).await
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
|
||||
@@ -629,7 +614,7 @@ pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeRes
|
||||
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_backend(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
|
||||
blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_commit(
|
||||
@@ -643,8 +628,8 @@ pub async fn vcs_commit(
|
||||
// Commit hooks may run for a long time.
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_backend_recorded(repo.as_deref(), None, |r| {
|
||||
r.commit(&message, &options, &|path| project.vcs_encoding(path, None))
|
||||
project.with_repo_recorded(repo.as_deref(), None, |r| {
|
||||
r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None))
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -901,7 +886,7 @@ pub async fn vcs_conflict_versions(host: &Host, window: &str, path: PathBuf) ->
|
||||
|
||||
pub async fn vcs_diff_text(host: &Host, window: &str, path: PathBuf, staged: bool) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_backend_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
|
||||
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1216,7 +1201,7 @@ pub async fn vcs_diff_with_worktree(
|
||||
rev: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
|
||||
}
|
||||
|
||||
/// Files that differ between two revisions (the "N files updated" of Update Project).
|
||||
@@ -1228,7 +1213,7 @@ pub async fn vcs_diff_revisions(
|
||||
to: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
}
|
||||
|
||||
/// Added and deleted lines between two revisions. `paths` limits the diff; empty diffs every file.
|
||||
|
||||
@@ -179,7 +179,7 @@ impl SecretStore for FileSecretStore {
|
||||
}
|
||||
|
||||
/// Largest response body a plugin receives.
|
||||
const MAX_RESPONSE: usize = 16 << 20;
|
||||
const MAX_RESPONSE: usize = 8 << 20;
|
||||
/// Largest stdout / stderr kept from a process.
|
||||
const MAX_OUTPUT: usize = 16 << 20;
|
||||
/// Most processes one plugin keeps running at once.
|
||||
@@ -1276,13 +1276,12 @@ pub(super) fn commit_draft(
|
||||
};
|
||||
// Both callers have checked that the window's project is trusted.
|
||||
r.set_execution_allowed(true);
|
||||
let backend = &r as &dyn ide_vcs::Backend;
|
||||
let pending = backend
|
||||
.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path)))
|
||||
.map_err(|e| match e.kind {
|
||||
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|
||||
|e| match e.kind {
|
||||
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
|
||||
_ => HostError::Failed(e.message),
|
||||
})?;
|
||||
},
|
||||
)?;
|
||||
let root = to_ipc(r.root());
|
||||
for c in pending {
|
||||
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
|
||||
@@ -1531,19 +1530,6 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn open_diff(
|
||||
&self,
|
||||
client: &str,
|
||||
diff: ide_plugin::ContentDiff,
|
||||
file_name: Option<String>,
|
||||
) -> Result<(), HostError> {
|
||||
super::execution::validate_diff(&diff)?;
|
||||
if let Some(name) = &file_name {
|
||||
super::execution::label(name, 256)?;
|
||||
}
|
||||
self.shared.sink.emit(Some(client), PluginEvent::OpenDiff { plugin_id: self.id().into(), diff, file_name });
|
||||
Ok(())
|
||||
}
|
||||
fn reply_content_tab(
|
||||
&self,
|
||||
client: &str,
|
||||
@@ -2281,7 +2267,7 @@ impl HostApi for PluginApi {
|
||||
let mut response = response;
|
||||
while let Some(chunk) = response.chunk().await.map_err(|e| HostError::Failed(e.to_string()))? {
|
||||
if body.len() + chunk.len() > MAX_RESPONSE {
|
||||
return Err(HostError::Failed(format!("response larger than {} MiB", MAX_RESPONSE >> 20)));
|
||||
return Err(HostError::Failed("response larger than 8 MiB".into()));
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
@@ -3963,72 +3949,6 @@ mod tests {
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_accepts_responses_up_to_16_mib_and_rejects_larger_bodies() {
|
||||
fn serve_bytes(total: usize) -> (u16, std::thread::JoinHandle<()>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let handle = std::thread::spawn(move || {
|
||||
use std::io::{Read, Write};
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buf = [0; 4096];
|
||||
let _ = stream.read(&mut buf);
|
||||
let header = format!("HTTP/1.1 200 OK\r\nContent-Length: {total}\r\nConnection: close\r\n\r\n");
|
||||
if stream.write_all(header.as_bytes()).is_err() {
|
||||
return;
|
||||
}
|
||||
let chunk = [b'x'; 64 << 10];
|
||||
let mut sent = 0;
|
||||
while sent < total {
|
||||
let n = (total - sent).min(chunk.len());
|
||||
if stream.write_all(&chunk[..n]).is_err() {
|
||||
break;
|
||||
}
|
||||
sent += n;
|
||||
}
|
||||
});
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = api(dir.path(), Arc::default());
|
||||
|
||||
let (ok_port, ok_worker) = serve_bytes(10 << 20);
|
||||
let resp = api
|
||||
.fetch(
|
||||
HttpRequest {
|
||||
method: "GET".into(),
|
||||
url: format!("http://127.0.0.1:{ok_port}/"),
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout: Duration::from_secs(10),
|
||||
grants: vec![Capability::Net("127.0.0.1".into())],
|
||||
},
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.expect("10 MiB response within 16 MiB limit succeeds");
|
||||
assert_eq!(resp.status, 200);
|
||||
assert_eq!(resp.body.len(), 10 << 20);
|
||||
ok_worker.join().unwrap();
|
||||
|
||||
let (big_port, big_worker) = serve_bytes(MAX_RESPONSE + (1 << 20));
|
||||
let err = api
|
||||
.fetch(
|
||||
HttpRequest {
|
||||
method: "GET".into(),
|
||||
url: format!("http://127.0.0.1:{big_port}/"),
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout: Duration::from_secs(10),
|
||||
grants: vec![Capability::Net("127.0.0.1".into())],
|
||||
},
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.expect_err("response exceeding 16 MiB fails explicitly");
|
||||
assert!(matches!(&err, HostError::Failed(msg) if msg.contains("response larger than 16 MiB")), "{err:?}");
|
||||
big_worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_ring_keeps_utf8_and_is_withdrawn_on_stop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -4125,11 +4045,6 @@ impl PluginApi {
|
||||
if items.len() > 10_000 || file.as_ref().and_then(|f| f.badge.as_ref()).is_some_and(|b| b.chars().count() > 2) {
|
||||
return Err(HostError::Invalid("decoration limit exceeded".into()));
|
||||
}
|
||||
if let Some(note) = file.as_ref().and_then(|f| f.note.as_ref())
|
||||
&& (note.trim().is_empty() || note.chars().count() > 200 || note.contains(['\n', '\r']))
|
||||
{
|
||||
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
|
||||
}
|
||||
items.retain(|d| decoration_style(&d.style));
|
||||
if items.iter().any(|d| d.range.start > d.range.end) {
|
||||
return Err(HostError::Invalid("reversed decoration range".into()));
|
||||
@@ -4148,7 +4063,7 @@ impl PluginApi {
|
||||
set_id: set.into(),
|
||||
tree,
|
||||
items,
|
||||
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
|
||||
file: file.filter(|f| decoration_style(&f.style)),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -4320,33 +4235,10 @@ mod workbench_tests {
|
||||
api.file_decorations(
|
||||
Some(&path),
|
||||
"tree",
|
||||
Some(ide_plugin::FileDecoration {
|
||||
badge: Some("abc".into()),
|
||||
style: "info".into(),
|
||||
tooltip: None,
|
||||
note: None
|
||||
})
|
||||
Some(ide_plugin::FileDecoration { badge: Some("abc".into()), style: "info".into(), tooltip: None })
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let note = |text: &str| ide_plugin::FileDecoration {
|
||||
badge: None,
|
||||
style: String::new(),
|
||||
tooltip: Some("why".into()),
|
||||
note: Some(text.into()),
|
||||
};
|
||||
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
|
||||
let notes = |api: &PluginApi| {
|
||||
api.shared.decoration_sets()["test"]
|
||||
.iter()
|
||||
.filter(|s| s.tree && s.set_id == "notes")
|
||||
.map(|s| s.file.as_ref().and_then(|f| f.note.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(notes(&api), [Some("entry point".to_string())], "kept without a style");
|
||||
for bad in ["", "two\nlines", &"x".repeat(201)] {
|
||||
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
|
||||
}
|
||||
api.shared.clear_plugin("test", true);
|
||||
assert!(api.shared.decoration_sets().is_empty());
|
||||
}
|
||||
|
||||
@@ -72,19 +72,14 @@ pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
|
||||
return Err(HostError::Invalid("content status or error too long".into()));
|
||||
}
|
||||
if let Some(diff) = &reply.diff {
|
||||
validate_diff(diff)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_diff(diff: &ide_plugin::ContentDiff) -> Result<(), HostError> {
|
||||
body(&diff.left)?;
|
||||
body(&diff.right)?;
|
||||
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
|
||||
label(value, 128)?;
|
||||
}
|
||||
if let Some(title) = &diff.title {
|
||||
label(title, 256)?;
|
||||
body(&diff.left)?;
|
||||
body(&diff.right)?;
|
||||
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
|
||||
label(value, 128)?;
|
||||
}
|
||||
if let Some(title) = &diff.title {
|
||||
label(title, 256)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -47,48 +47,6 @@ pub const SECRET_REQUEST: &str = "plugin_secret";
|
||||
pub const SECRET_WRITE: &str = "plugin_secret_write";
|
||||
/// OAuth listeners bound on the desktop, never the project server.
|
||||
pub const AUTH_REQUEST: &str = "plugin_auth";
|
||||
/// What [`RemotePlugins::register`] adds to a server's commands.
|
||||
pub const COMMANDS: &[&str] = &[
|
||||
SYNC_PACKAGES,
|
||||
"plugin_run_command",
|
||||
"plugin_set_ui_locale",
|
||||
"plugin_select_tests",
|
||||
"plugin_parse_test_results",
|
||||
"plugin_terminal_session_close",
|
||||
"plugin_terminal_session_spawn",
|
||||
"plugin_prepare_debug_run",
|
||||
"plugin_finish_operation",
|
||||
"plugin_content_tab_action",
|
||||
"plugin_post_events",
|
||||
"plugin_event_kinds",
|
||||
"plugin_status_items",
|
||||
"plugin_context_keys",
|
||||
"plugin_cancel_task",
|
||||
"plugin_answer_prompt",
|
||||
"plugin_choose_program",
|
||||
"plugin_program",
|
||||
"plugin_answer_command",
|
||||
"plugin_answer_commit",
|
||||
"plugin_commit_checks",
|
||||
"mcp_set_open_editors",
|
||||
"mcp_set_focused_editor",
|
||||
"mcp_report_problems",
|
||||
"plugin_decoration_sets",
|
||||
"plugin_set_active_file",
|
||||
"plugin_view_render",
|
||||
"plugin_view_event",
|
||||
"plugin_service_detail",
|
||||
"plugin_service_log",
|
||||
"plugin_navigation_files",
|
||||
"plugin_navigation_invalidate",
|
||||
"plugin_navigation_index",
|
||||
"plugin_navigation_goto",
|
||||
"plugin_navigation_markers",
|
||||
"plugin_endpoints",
|
||||
"plugin_run_targets",
|
||||
"plugin_answer_run_target",
|
||||
"plugin_line_markers",
|
||||
];
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct SecretWrite {
|
||||
@@ -176,25 +134,20 @@ pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult<String> {
|
||||
}
|
||||
|
||||
/// Whether a plugin event means something to a remote window (the desktop
|
||||
/// owns the plugin list, themes and consent). The desktop checks it again on
|
||||
/// arrival: a server may send anything, and only what acts on the remote project
|
||||
/// passes. A command may only be another plugin command, which runs on the server.
|
||||
/// owns the plugin list, themes and consent).
|
||||
pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
if let PluginEvent::ExecuteCommand { id, .. } = event {
|
||||
return plugin_command(id);
|
||||
}
|
||||
matches!(
|
||||
event,
|
||||
PluginEvent::RunTargetsChanged
|
||||
| PluginEvent::WindowRequest { .. }
|
||||
| PluginEvent::ContentTabReply { .. }
|
||||
| PluginEvent::OpenDiff { .. }
|
||||
| PluginEvent::DecorationsChanged { .. }
|
||||
| PluginEvent::EditorOpen { .. }
|
||||
| PluginEvent::EditorRanges { .. }
|
||||
| PluginEvent::Notification { .. }
|
||||
| PluginEvent::StatusItemChanged { .. }
|
||||
| PluginEvent::WindowStatusItemChanged { .. }
|
||||
| PluginEvent::ExecuteCommand { .. }
|
||||
| PluginEvent::OutputChanged { .. }
|
||||
| PluginEvent::RevealOutput { .. }
|
||||
| PluginEvent::ContextChanged { .. }
|
||||
@@ -210,6 +163,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::OpenLogTab { .. }
|
||||
| PluginEvent::CopyToClipboard { .. }
|
||||
| PluginEvent::OpenUrl { .. }
|
||||
| PluginEvent::OpenProject { .. }
|
||||
| PluginEvent::PromptRequested { .. }
|
||||
| PluginEvent::PromptClosed { .. }
|
||||
| PluginEvent::PromptWaiting { .. }
|
||||
@@ -219,13 +173,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// `plugin.<plugin id>.<command id>`, as the workbench names plugin commands.
|
||||
fn plugin_command(id: &str) -> bool {
|
||||
id.strip_prefix("plugin.")
|
||||
.and_then(|rest| rest.split_once('.'))
|
||||
.is_some_and(|(plugin, command)| !plugin.is_empty() && !command.is_empty() && !command.ends_with('.'))
|
||||
}
|
||||
|
||||
// ---- desktop side -----------------------------------------------------------
|
||||
|
||||
impl PluginHost {
|
||||
@@ -1004,43 +951,4 @@ mod tests {
|
||||
assert!(valid_id("ai-completion"));
|
||||
assert!(!valid_id("../x") && !valid_id(".data") && !valid_id(""));
|
||||
}
|
||||
|
||||
fn execute(id: &str) -> PluginEvent {
|
||||
PluginEvent::ExecuteCommand {
|
||||
request_id: "1".into(),
|
||||
id: id.into(),
|
||||
arg: Some(r"\\host.example\share\pkg".into()),
|
||||
chain: vec!["tools".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_runs_only_plugin_commands_in_the_desktop_window() {
|
||||
assert!(forwarded(&execute("plugin.tools.build")));
|
||||
assert!(forwarded(&execute("plugin.acme.tools.build.all")));
|
||||
for id in [
|
||||
"plugins.installFromDisk",
|
||||
"project.revokeTrust",
|
||||
"project.removeTrustedLocation",
|
||||
"ssh.startSession",
|
||||
"file.openProjectPath",
|
||||
"terminal.runCommand",
|
||||
"plugin.",
|
||||
"plugin.tools",
|
||||
"plugin..build",
|
||||
"plugin.tools.",
|
||||
] {
|
||||
assert!(!forwarded(&execute(id)), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_does_not_open_desktop_folders() {
|
||||
let open = PluginEvent::OpenProject {
|
||||
plugin_id: "tools".into(),
|
||||
path: r"\\host.example\share\project".into(),
|
||||
new_window: true,
|
||||
};
|
||||
assert!(!forwarded(&open));
|
||||
}
|
||||
}
|
||||
@@ -218,9 +218,6 @@ pub enum PluginEvent {
|
||||
/// Reply to a content action in this window.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ContentTabReply { plugin_id: String, request_id: String, reply: ide_plugin::ContentTabReply },
|
||||
/// Open two texts in this window's diff viewer.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenDiff { plugin_id: String, diff: ide_plugin::ContentDiff, file_name: Option<String> },
|
||||
|
||||
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
|
||||
CommandsChanged,
|
||||
|
||||
@@ -8,7 +8,7 @@ use ide_language::{FileTypes, LanguageRegistry, SyntaxCache};
|
||||
use ide_lsp::LspManager;
|
||||
use ide_syntax::StructureService;
|
||||
use ide_text::{AppliedChange, ByteEdit};
|
||||
use ide_vcs::{Backend, Repo, Repos};
|
||||
use ide_vcs::{Repo, Repos};
|
||||
use ide_vfs::Watcher;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
|
||||
@@ -541,7 +541,7 @@ impl Host {
|
||||
mark("git");
|
||||
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
|
||||
|
||||
let (db, http_history, agents) = self.project_services(&root, &project_key, &settings, &sink);
|
||||
let (db, http_history, agents) = self.project_services(&root, &project_key, &settings);
|
||||
let db_sources_watch = db_sources_watch(&db, &sink, &self.runtime);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
@@ -634,7 +634,6 @@ impl Host {
|
||||
root: &Path,
|
||||
project_key: &str,
|
||||
settings: &Arc<SettingsStore>,
|
||||
sink: &SharedSink,
|
||||
) -> (Arc<ide_db::DbManager>, Arc<ide_http::History>, Arc<ide_agent::AgentManager>) {
|
||||
let db = Arc::new(
|
||||
ide_db::DbManager::new(
|
||||
@@ -661,23 +660,12 @@ impl Host {
|
||||
models_file: self.data_dir.join("agents").join("models.json"),
|
||||
custom_agents_dir: Some(self.config_dir.join("agents")),
|
||||
home_dir: dirs::home_dir(),
|
||||
env_secrets: Some(Arc::new(AgentEnvSecrets {
|
||||
secrets: self.agent_secrets.clone(),
|
||||
sink: sink.clone(),
|
||||
told: AtomicBool::new(false),
|
||||
})),
|
||||
},
|
||||
self.runtime.clone(),
|
||||
));
|
||||
let settings = settings.clone();
|
||||
{
|
||||
let settings = settings.clone();
|
||||
agents.set_new_task_approvals_hook(Arc::new(move || settings.effective().agents.new_task_approvals));
|
||||
}
|
||||
let approvals = self.mcp_approvals.clone();
|
||||
let root = root.to_path_buf();
|
||||
agents.set_mcp_servers_hook(Box::new(move || {
|
||||
crate::ops::agent::session_mcp_servers(&settings, &approvals, &root)
|
||||
ide_agent::mcp::servers_from_settings(&Default::default(), &settings.effective().agents.mcp_servers)
|
||||
}));
|
||||
(db, http_history, agents)
|
||||
}
|
||||
@@ -720,7 +708,7 @@ impl Host {
|
||||
documents.add_observer(Arc::new(HistoryObserver(history.clone())));
|
||||
let watcher = light_watcher(&dir, &sink, &documents, &history)?;
|
||||
let project_key = project_key(&dir);
|
||||
let (db, http_history, agents) = self.project_services(&dir, &project_key, &settings, &sink);
|
||||
let (db, http_history, agents) = self.project_services(&dir, &project_key, &settings);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
sink.clone(),
|
||||
@@ -971,11 +959,6 @@ impl Project {
|
||||
}
|
||||
}
|
||||
|
||||
/// `with_repo_of` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_backend_of<T>(&self, path: &Path, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_repo_of(path, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
let settings = self.settings.effective();
|
||||
if self.is_trusted() {
|
||||
@@ -985,40 +968,6 @@ impl Project {
|
||||
f(repo)
|
||||
}
|
||||
|
||||
/// `with_repo_at` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_backend_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_repo_at(at, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
/// `with_log_repo_at` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_log_backend_at<T>(
|
||||
&self,
|
||||
at: Option<&Path>,
|
||||
f: impl FnOnce(&dyn Backend) -> IdeResult<T>,
|
||||
) -> IdeResult<T> {
|
||||
self.with_log_repo_at(at, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
/// `with_log_repo_of` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_log_backend_of<T>(&self, path: &Path, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_log_repo_of(path, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
/// `with_repo` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_backend<T>(&self, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_backend_at(None, f)
|
||||
}
|
||||
|
||||
/// `with_repo_recorded` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_backend_recorded<T>(
|
||||
&self,
|
||||
at: Option<&Path>,
|
||||
operation: Option<&str>,
|
||||
f: impl FnOnce(&dyn Backend) -> IdeResult<T>,
|
||||
) -> IdeResult<T> {
|
||||
self.with_repo_recorded(at, operation, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
/// Runs `f` with the project's primary git repository, or fails when the project is not under git.
|
||||
pub fn with_repo<T>(&self, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_repo_at(None, f)
|
||||
@@ -1043,7 +992,7 @@ impl Project {
|
||||
) -> IdeResult<T> {
|
||||
let Some(name) = operation else { return self.with_disk_sync(|| self.with_repo_at(at, f)) };
|
||||
self.history_label(&format!("Before {name}"));
|
||||
if let Ok(changed) = self.with_backend_at(at, |r| r.status()) {
|
||||
if let Ok(changed) = self.with_repo_at(at, |r| r.status()) {
|
||||
for file in changed.iter().take(HISTORY_BATCH_LIMIT) {
|
||||
if let Err(e) = self.history.record_file(&file.path, &format!("Before {name}"), false) {
|
||||
tracing::debug!("local history: {e}");
|
||||
@@ -1637,37 +1586,6 @@ pub(crate) fn project_key(root: &Path) -> String {
|
||||
format!("{h:016x}")
|
||||
}
|
||||
|
||||
/// The environment values of agent files in [`crate::agent_secrets::AgentSecrets`]; the
|
||||
/// window is told (once) when they had to go to the owner-only file instead.
|
||||
struct AgentEnvSecrets {
|
||||
secrets: Arc<crate::agent_secrets::AgentSecrets>,
|
||||
sink: SharedSink,
|
||||
told: AtomicBool,
|
||||
}
|
||||
|
||||
impl ide_agent::EnvSecrets for AgentEnvSecrets {
|
||||
fn get(&self, key: &str) -> Option<String> {
|
||||
self.secrets.get(key)
|
||||
}
|
||||
|
||||
fn set(&self, key: &str, value: &str) -> IdeResult<()> {
|
||||
if self.secrets.set(key, value)? == crate::agent_secrets::Kept::LocalFile
|
||||
&& !self.told.swap(true, Ordering::Relaxed)
|
||||
{
|
||||
self.secrets.notify_kept_locally(&self.sink);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) {
|
||||
self.secrets.delete(key);
|
||||
}
|
||||
|
||||
fn keys(&self, prefix: &str) -> Vec<String> {
|
||||
self.secrets.keys(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
struct LspObserver(Arc<LspManager>);
|
||||
|
||||
impl DocumentObserver for LspObserver {
|
||||
|
||||
+3
-106
@@ -185,15 +185,15 @@ impl Dispatcher {
|
||||
.add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await })
|
||||
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_create_file_bytes", |c| async move {
|
||||
ops::fs::create_file_bytes(&c.host, &c.window, c.arg("path")?, c.arg("data")?).await
|
||||
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
|
||||
})
|
||||
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
|
||||
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, &c.window, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_set_writable", |c| async move {
|
||||
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
|
||||
})
|
||||
.add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, &c.window, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_copy_merge", |c| async move {
|
||||
ops::fs::copy_merge(
|
||||
&c.host,
|
||||
@@ -298,56 +298,6 @@ pub fn event_sink(peer: Arc<OnceLock<Peer>>, window: Option<String>) -> ide_core
|
||||
Arc::new(PeerSink { peer, window })
|
||||
}
|
||||
|
||||
/// Whether a desktop shows an `event` notification of its server in the window. A server reports
|
||||
/// on its project; what this machine answers or owns itself (MCP approvals, app updates, window
|
||||
/// groups) never comes from it.
|
||||
pub fn client_accepts(event: &IdeEvent) -> bool {
|
||||
match event {
|
||||
IdeEvent::McpRequest { .. }
|
||||
| IdeEvent::McpRequestClosed { .. }
|
||||
| IdeEvent::WindowGroupsChanged
|
||||
| IdeEvent::WindowFullscreenChanged
|
||||
| IdeEvent::UpdateStatusChanged { .. }
|
||||
| IdeEvent::UpdateAvailable { .. } => false,
|
||||
IdeEvent::DbSourcesChanged
|
||||
| IdeEvent::ProjectTrustChanged { .. }
|
||||
| IdeEvent::FsChanged { .. }
|
||||
| IdeEvent::ExcludedDirsChanged { .. }
|
||||
| IdeEvent::DocPatched { .. }
|
||||
| IdeEvent::DocDirty { .. }
|
||||
| IdeEvent::WorkspaceEditApplied { .. }
|
||||
| IdeEvent::DocLanguage { .. }
|
||||
| IdeEvent::Diagnostics { .. }
|
||||
| IdeEvent::Task { .. }
|
||||
| IdeEvent::IndexState { .. }
|
||||
| IdeEvent::VcsChanged { .. }
|
||||
| IdeEvent::LanguageServer { .. }
|
||||
| IdeEvent::LanguageServerWarning { .. }
|
||||
| IdeEvent::LanguageServerRemoved { .. }
|
||||
| IdeEvent::LspRefresh { .. }
|
||||
| IdeEvent::TerminalExited { .. }
|
||||
| IdeEvent::RunExited { .. }
|
||||
| IdeEvent::Debug { .. }
|
||||
| IdeEvent::Notify { .. }
|
||||
| IdeEvent::SettingsChanged { .. }
|
||||
| IdeEvent::ProjectRootsChanged { .. }
|
||||
| IdeEvent::Askpass { .. }
|
||||
| IdeEvent::AskpassDone { .. }
|
||||
| IdeEvent::NotebookKernel { .. } => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The commands a desktop routes to its server: what `openidea-server` of this build answers,
|
||||
/// fixed here rather than taken from the server, so a server cannot move a command to this machine.
|
||||
pub fn client_commands() -> Vec<&'static str> {
|
||||
let mut out = Dispatcher::standard().commands();
|
||||
out.extend(crate::plugins::remote_host::COMMANDS);
|
||||
out.push(crate::plugins::remote::SYNC_COMMAND);
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
struct PeerSink {
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
window: Option<String>,
|
||||
@@ -414,56 +364,3 @@ pub fn error_from_reply(e: ide_jsonrpc::RpcError) -> IdeError {
|
||||
other => other.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::event::{AskpassKind, McpUiRequest, NotifyLevel};
|
||||
|
||||
#[test]
|
||||
fn a_server_cannot_raise_desktop_prompts() {
|
||||
let confirm = McpUiRequest::Confirm {
|
||||
client: "agent".into(),
|
||||
tool: "run".into(),
|
||||
subject: "rm -rf".into(),
|
||||
project: "/srv/app".into(),
|
||||
detail: None,
|
||||
};
|
||||
let update = ide_core::updates::UpdateInfo {
|
||||
version: "9.9.9".into(),
|
||||
current_version: "0.1.0".into(),
|
||||
notes: None,
|
||||
date: None,
|
||||
};
|
||||
for event in [
|
||||
IdeEvent::McpRequest { id: 1, request: confirm },
|
||||
IdeEvent::McpRequestClosed { id: 1 },
|
||||
IdeEvent::UpdateAvailable { update },
|
||||
IdeEvent::UpdateStatusChanged {
|
||||
status: ide_core::updates::UpdateStatus { last_checked: None, checking: true },
|
||||
},
|
||||
IdeEvent::WindowGroupsChanged,
|
||||
IdeEvent::WindowFullscreenChanged,
|
||||
] {
|
||||
assert!(!client_accepts(&event), "{event:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_reports_its_project_to_the_desktop() {
|
||||
for event in [
|
||||
IdeEvent::FsChanged {
|
||||
created: vec!["/srv/app/a".into()],
|
||||
modified: vec![],
|
||||
removed: vec![],
|
||||
ignore_rules: vec![],
|
||||
},
|
||||
IdeEvent::VcsChanged { root: "/srv/app".into() },
|
||||
IdeEvent::Notify { level: NotifyLevel::Info, title: "Build".into(), body: None },
|
||||
IdeEvent::Askpass { id: 1, prompt: "Password".into(), kind: AskpassKind::Secret, remember: false },
|
||||
IdeEvent::ProjectTrustChanged { root: "/srv/app".into(), trusted: true },
|
||||
] {
|
||||
assert!(client_accepts(&event), "{event:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,15 +18,6 @@ pub struct TrustStore {
|
||||
state: Mutex<IdeResult<TrustDecisions>>,
|
||||
/// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]).
|
||||
inherited: Mutex<Vec<PathBuf>>,
|
||||
remote_file: PathBuf,
|
||||
remote: Mutex<IdeResult<RemoteDecisions>>,
|
||||
}
|
||||
|
||||
/// This machine's decisions about remote projects, by location (see [`TrustStore::remote_decision`]).
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
struct RemoteDecisions {
|
||||
trusted: Vec<String>,
|
||||
restricted: Vec<String>,
|
||||
}
|
||||
|
||||
/// Set by the desktop when it starts the server in a dev container of a folder the user trusts
|
||||
@@ -49,37 +40,7 @@ impl TrustStore {
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
};
|
||||
let remote_file = data.join("remote-project-trust.json");
|
||||
let remote = match std::fs::read(&remote_file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RemoteDecisions::default()),
|
||||
Err(e) => Err(e.into()),
|
||||
};
|
||||
Self { file, state: Mutex::new(state), inherited: Mutex::default(), remote_file, remote: Mutex::new(remote) }
|
||||
}
|
||||
|
||||
/// The decision made on this machine for the remote project at `location` (`None`: none yet).
|
||||
/// Commands a remote window runs here follow it, not the server's own trust.
|
||||
pub fn remote_decision(&self, location: &str) -> IdeResult<Option<bool>> {
|
||||
let remote = self.remote.lock().clone()?;
|
||||
Ok(if remote.restricted.iter().any(|l| l == location) {
|
||||
Some(false)
|
||||
} else if remote.trusted.iter().any(|l| l == location) {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decide_remote(&self, location: &str, trusted: bool) -> IdeResult<()> {
|
||||
let mut state = self.remote.lock();
|
||||
let mut next = state.clone()?;
|
||||
next.trusted.retain(|l| l != location);
|
||||
next.restricted.retain(|l| l != location);
|
||||
if trusted { &mut next.trusted } else { &mut next.restricted }.push(location.to_owned());
|
||||
persist(&self.remote_file, &next)?;
|
||||
*state = Ok(next);
|
||||
Ok(())
|
||||
Self { file, state: Mutex::new(state), inherited: Mutex::default() }
|
||||
}
|
||||
|
||||
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
|
||||
@@ -172,7 +133,7 @@ impl TrustStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn persist<T: Serialize>(file: &Path, state: &T) -> IdeResult<()> {
|
||||
fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
|
||||
if let Some(parent) = file.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
@@ -181,61 +142,6 @@ fn persist<T: Serialize>(file: &Path, state: &T) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type McpServers = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
|
||||
|
||||
/// Workspace MCP servers (a project's `agents.mcpServers`) the user turned on in Settings, by a
|
||||
/// fingerprint of the project and the server's definition. Agents start a workspace server only
|
||||
/// while its definition is one the user turned on, so a server added or changed in the project's
|
||||
/// file stays off until then.
|
||||
pub struct McpServerApprovals {
|
||||
file: PathBuf,
|
||||
approved: Mutex<std::collections::BTreeSet<String>>,
|
||||
}
|
||||
|
||||
impl McpServerApprovals {
|
||||
pub fn load(data: &Path) -> Self {
|
||||
let file = data.join("mcp-server-approvals.json");
|
||||
let approved = std::fs::read(&file).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
Self { file, approved: Mutex::new(approved) }
|
||||
}
|
||||
|
||||
fn fingerprint(root: &Path, name: &str, server: &ide_core::settings::AgentMcpServer) -> String {
|
||||
// Turning a server off and on again doesn't change what it runs.
|
||||
let server = ide_core::settings::AgentMcpServer { enabled: true, ..server.clone() };
|
||||
let bytes = serde_json::to_vec(&(root, name, server)).expect("MCP server serializes");
|
||||
blake3::hash(&bytes).to_hex().to_string()
|
||||
}
|
||||
|
||||
pub fn approved(&self, root: &Path, name: &str, server: &ide_core::settings::AgentMcpServer) -> bool {
|
||||
self.approved.lock().contains(&Self::fingerprint(root, name, server))
|
||||
}
|
||||
|
||||
pub fn approve(&self, root: &Path, name: &str, server: &ide_core::settings::AgentMcpServer) -> IdeResult<()> {
|
||||
let fingerprint = Self::fingerprint(root, name, server);
|
||||
let mut approved = self.approved.lock();
|
||||
if approved.contains(&fingerprint) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut next = approved.clone();
|
||||
next.insert(fingerprint);
|
||||
if let Some(parent) = self.file.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&next).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
ide_vfs::write_atomic(&self.file, &bytes)?;
|
||||
*approved = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `workspace` with the servers the user hasn't turned on switched off.
|
||||
pub fn apply(&self, root: &Path, mut workspace: McpServers) -> McpServers {
|
||||
for (name, server) in &mut workspace {
|
||||
server.enabled &= self.approved(root, name, server);
|
||||
}
|
||||
workspace
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Host {
|
||||
pub async fn set_project_trust(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
|
||||
self.trust.decide(path, trusted, folder)?;
|
||||
@@ -411,8 +317,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "remote_current"
|
||||
| "remote_disconnect"
|
||||
| "remote_connect"
|
||||
| "remote_trust_status"
|
||||
| "remote_trust_set"
|
||||
| "remote_bootstrap_list"
|
||||
| "wsl_distros"
|
||||
| "wsl_resolve"
|
||||
@@ -567,18 +471,4 @@ mod tests {
|
||||
fn restricted_mode_allows_clipboard_write_text() {
|
||||
assert!(super::restricted_command_allowed("clipboard_write_text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_project_decisions_are_kept_on_this_machine() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let load = || super::TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
|
||||
let store = load();
|
||||
let here = r#"[{"kind":"ssh","config":"box"},"/srv/app"]"#;
|
||||
assert_eq!(store.remote_decision(here).unwrap(), None);
|
||||
store.decide_remote(here, true).unwrap();
|
||||
assert_eq!(store.remote_decision(here).unwrap(), Some(true));
|
||||
assert_eq!(store.remote_decision(r#"[{"kind":"ssh","config":"box"},"/srv/other"]"#).unwrap(), None);
|
||||
store.decide_remote(here, false).unwrap();
|
||||
assert_eq!(load().remote_decision(here).unwrap(), Some(false));
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//! The mode new agent tasks start in.
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig};
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Codex's modes as it reported them last time, in "ask".
|
||||
fn remember_codex_modes(data: &Path) {
|
||||
std::fs::create_dir_all(data.join("agents")).unwrap();
|
||||
let modes = json!({ "codex": { "config": [ {
|
||||
"id": "mode", "name": "Mode", "description": null, "category": "mode", "current": "ask",
|
||||
"values": [ { "value": "read-only", "name": "Read Only", "description": null },
|
||||
{ "value": "ask", "name": "Ask", "description": null },
|
||||
{ "value": "full-access", "name": "Full Access", "description": null } ],
|
||||
"via": "option" } ] } });
|
||||
std::fs::write(data.join("agents/models.json"), modes.to_string()).unwrap();
|
||||
}
|
||||
|
||||
fn codex_mode(host: &Host) -> Option<String> {
|
||||
let providers = ide_host::ops::agent::providers(host, "main").unwrap();
|
||||
let codex = providers.into_iter().find(|p| p.id == "codex").expect("codex is listed");
|
||||
codex.config.into_iter().find(|o| o.id == "mode").and_then(|o| o.current)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn new_tasks_start_asking_for_approval_when_the_user_chose_so() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let (config, data) = (tmp.path().join("config"), tmp.path().join("data"));
|
||||
remember_codex_modes(&data);
|
||||
std::fs::create_dir_all(&config).unwrap();
|
||||
std::fs::write(config.join("settings.toml"), "[agents]\nnewTaskApprovals = \"ask\"\n").unwrap();
|
||||
// A project's own settings don't choose this.
|
||||
std::fs::write(root.join(".openidea/settings.toml"), "[agents]\nnewTaskApprovals = \"bypass\"\n").unwrap();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: config.clone(),
|
||||
data_dir: data,
|
||||
local_data_dir: tmp.path().join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
assert_eq!(codex_mode(&host).as_deref(), Some("ask"));
|
||||
|
||||
// The default keeps starting new tasks without approvals.
|
||||
std::fs::remove_file(config.join("settings.toml")).unwrap();
|
||||
host.project("main").unwrap().settings.reload();
|
||||
assert_eq!(codex_mode(&host).as_deref(), Some("full-access"));
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
//! Values of agents' environment variables live in the credential store; agent files keep only their names.
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_ssh::secrets::testing::{files_containing, use_memory_store};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
const VALUE: &str = "agent-env-value-5b2d81";
|
||||
|
||||
fn host(dir: &Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: dir.join("config"),
|
||||
data_dir: dir.join("data"),
|
||||
local_data_dir: dir.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn open(host: &Host, tmp: &tempfile::TempDir) {
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
}
|
||||
|
||||
fn env_of(host: &Host, id: &str) -> BTreeMap<String, String> {
|
||||
let providers = ide_host::ops::agent::providers(host, "main").unwrap();
|
||||
providers.into_iter().find(|p| p.id == id).unwrap_or_else(|| panic!("{id}")).env
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn agent_environment_values_stay_out_of_agent_files() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
open(&host, &tmp);
|
||||
let env = BTreeMap::from([("ANTHROPIC_API_KEY".to_owned(), VALUE.to_owned())]);
|
||||
ide_host::ops::agent::set_env(&host, "main", "claude".into(), env.clone()).unwrap();
|
||||
ide_host::ops::agent::save_custom_agent(
|
||||
&host,
|
||||
"main",
|
||||
ide_agent::CustomAgentInput {
|
||||
id: "mine".into(),
|
||||
name: "Mine".into(),
|
||||
command: "/opt/mine".into(),
|
||||
args: vec![],
|
||||
env: env.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
host.close_project("main");
|
||||
assert_eq!(files_containing(tmp.path(), VALUE), Vec::<PathBuf>::new());
|
||||
|
||||
let host = self::host(tmp.path());
|
||||
open(&host, &tmp);
|
||||
assert_eq!(env_of(&host, "claude"), env);
|
||||
assert_eq!(env_of(&host, "mine"), env);
|
||||
ide_host::ops::agent::remove_custom_agent(&host, "main", "mine".into()).unwrap();
|
||||
ide_host::ops::agent::set_env(&host, "main", "claude".into(), BTreeMap::new()).unwrap();
|
||||
assert_eq!(host.agent_secrets.keys("agent/"), Vec::<String>::new());
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn values_in_agent_files_move_to_the_credential_store() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("config/agents")).unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("config/agents/mine.toml"),
|
||||
format!("command = \"/opt/mine\"\n[env]\nTOKEN = \"{VALUE}\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let host = host(tmp.path());
|
||||
open(&host, &tmp);
|
||||
assert_eq!(files_containing(tmp.path(), VALUE), Vec::<PathBuf>::new());
|
||||
assert_eq!(env_of(&host, "mine"), BTreeMap::from([("TOKEN".to_owned(), VALUE.to_owned())]));
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
//! External MCP servers in Settings › Agents, as the host stores and lists them.
|
||||
|
||||
use ide_agent::{McpScope, McpServer, McpServerInput};
|
||||
use ide_core::NullSink;
|
||||
use ide_core::settings::McpTransport;
|
||||
use ide_host::ops::agent::{mcp_import, mcp_list, mcp_save, mcp_set_enabled};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn open(base: &Path) -> (Arc<Host>, PathBuf) {
|
||||
let root = dunce::canonicalize(base).unwrap().join("project");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
(host, root)
|
||||
}
|
||||
|
||||
fn enabled(list: &[McpServer], id: &str) -> bool {
|
||||
list.iter().find(|s| s.id == id).unwrap_or_else(|| panic!("{id} not listed: {list:?}")).enabled
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn workspace_servers_from_the_project_file_stay_off_until_the_user_turns_them_on() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (host, root) = open(tmp.path());
|
||||
let settings_file = root.join(".openidea/settings.toml");
|
||||
std::fs::write(&settings_file, "[agents.mcpServers.helper]\ncommand = \"node\"\nargs = [\"helper.js\"]\n").unwrap();
|
||||
host.project("main").unwrap().settings.reload();
|
||||
|
||||
assert!(!enabled(&mcp_list(&host, "main").unwrap(), "workspace::helper"));
|
||||
assert!(enabled(&mcp_set_enabled(&host, "main", "workspace::helper".into(), true).unwrap(), "workspace::helper"));
|
||||
assert!(enabled(&mcp_list(&host, "main").unwrap(), "workspace::helper"));
|
||||
|
||||
// A changed definition is off again until turned back on.
|
||||
std::fs::write(&settings_file, "[agents.mcpServers.helper]\ncommand = \"node\"\nargs = [\"other.js\"]\n").unwrap();
|
||||
host.project("main").unwrap().settings.reload();
|
||||
assert!(!enabled(&mcp_list(&host, "main").unwrap(), "workspace::helper"));
|
||||
|
||||
// What the user adds in Settings is on.
|
||||
let saved = mcp_save(
|
||||
&host,
|
||||
"main",
|
||||
McpServerInput {
|
||||
id: None,
|
||||
name: "mine".into(),
|
||||
scope: McpScope::Workspace,
|
||||
transport: McpTransport::Stdio,
|
||||
enabled: true,
|
||||
command: Some("uvx".into()),
|
||||
args: vec!["mcp-server-git".into()],
|
||||
env: vec![],
|
||||
url: None,
|
||||
headers: vec![],
|
||||
agents: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(enabled(&saved, "workspace::mine"));
|
||||
|
||||
// Imported servers come from the project's own files: off until turned on.
|
||||
std::fs::write(root.join(".mcp.json"), r#"{"mcpServers":{"imported":{"command":"uvx","args":["x"]}}}"#).unwrap();
|
||||
let imported = mcp_import(&host, "main").unwrap();
|
||||
assert!(!enabled(&imported, "workspace::imported"));
|
||||
assert!(enabled(&imported, "workspace::mine"));
|
||||
}
|
||||
@@ -112,16 +112,6 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
|
||||
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");
|
||||
// The matched line comes with the result (unsaved text included), and lines around it on request.
|
||||
assert_eq!(all["items"][0]["lineText"], "fresh needle text");
|
||||
assert!(all["items"][0].get("contextAfter").is_none());
|
||||
let after = h.json("search_text", json!({"q":"fresh","context":"+1"})).await;
|
||||
assert_eq!(after["items"][0]["contextAfter"], json!(["needle needle"]));
|
||||
let before = h.json("search_regex", json!({"q":"needle needle","context":"-1"})).await;
|
||||
assert_eq!(before["items"][0]["lineText"], "needle needle");
|
||||
assert_eq!(before["items"][0]["contextBefore"], json!(["fresh needle text"]));
|
||||
let bad = h.error("search_text", json!({"q":"fresh","context":"around"})).await;
|
||||
assert!(bad.contains("context must be"), "{bad}");
|
||||
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;
|
||||
@@ -231,11 +221,7 @@ async fn mutations_preserve_unsaved_text_and_reject_metadata_and_escapes() {
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|event| match event {
|
||||
// Undoable like a refactoring, but marked as the agent's, so it isn't announced as one.
|
||||
IdeEvent::WorkspaceEditApplied { id, by_agent, .. } => {
|
||||
assert!(*by_agent, "an MCP edit is the agent's");
|
||||
Some(*id)
|
||||
}
|
||||
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
@@ -467,166 +453,3 @@ async fn brave_mode_runs_tools_without_asking_only_from_the_global_settings() {
|
||||
assert!(!format!("{result:?}").contains("denied"), "{result:?}");
|
||||
assert!(!h.events.lock().unwrap().iter().any(|e| matches!(e, IdeEvent::McpRequest { .. })), "nothing asked");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn check_files_says_why_a_file_could_not_be_checked() {
|
||||
let h = Harness::new();
|
||||
let untrusted = h.json("check_files", json!({"filePaths": ["src/a.txt"]})).await;
|
||||
assert_eq!(untrusted["files"][0]["status"], "untrusted", "{untrusted}");
|
||||
h.trust().await;
|
||||
let files = h.json("check_files", json!({"filePaths": ["src/a.txt", "src/gone.txt"], "timeout": 5000})).await;
|
||||
assert_eq!(files["files"][0]["filePath"], "src/a.txt");
|
||||
assert_eq!(files["files"][0]["status"], "noLanguageServer", "{files}");
|
||||
assert_eq!(files["files"][0]["problems"], json!([]));
|
||||
assert_eq!(files["files"][1]["status"], "failed", "{files}");
|
||||
assert!(files["files"][1]["error"].is_string());
|
||||
// No cap of the tool's own: a very long timeout is taken as given, without overflowing.
|
||||
let long = h.json("check_files", json!({"filePaths": ["src/a.txt"], "timeout": 9_000_000_000_000_i64})).await;
|
||||
assert_eq!(long["files"][0]["status"], "noLanguageServer", "{long}");
|
||||
let none = h.error("check_files", json!({"filePaths": []})).await;
|
||||
assert!(none.contains("at least one file"), "{none}");
|
||||
}
|
||||
|
||||
/// Answers `run_command`'s window requests the way the Run tool window does: starts the
|
||||
/// agent tab's configuration (shell from the default) and says whether it started. Returns
|
||||
/// the configurations it started, for reruns.
|
||||
#[cfg(unix)]
|
||||
fn run_tool_window(h: &Harness) -> Arc<std::sync::Mutex<Vec<ide_run::RunConfig>>> {
|
||||
let started = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let (host, events, out) = (h.host.clone(), h.events.clone(), started.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut handled = std::collections::HashSet::new();
|
||||
loop {
|
||||
let request = events.lock().unwrap().iter().find_map(|e| match e {
|
||||
IdeEvent::McpRequest {
|
||||
id,
|
||||
request: ide_core::event::McpUiRequest::AgentRun { name, command, cwd, env },
|
||||
} if !handled.contains(id) => Some((*id, name.clone(), command.clone(), cwd.clone(), env.clone())),
|
||||
_ => None,
|
||||
});
|
||||
let Some((id, name, command, cwd, env)) = request else {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
continue;
|
||||
};
|
||||
handled.insert(id);
|
||||
let mut config = ide_run::RunConfig {
|
||||
id: format!("mcp-agent:{name}"),
|
||||
name: name.clone(),
|
||||
kind: "shell".into(),
|
||||
command,
|
||||
args: Vec::new(),
|
||||
cwd: Some(cwd),
|
||||
env,
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: true,
|
||||
extras: Default::default(),
|
||||
};
|
||||
config.extras.params.insert(ide_host::mcp::AGENT_PARAM.into(), name);
|
||||
config.extras.target = Some(String::new());
|
||||
out.lock().unwrap().push(config.clone());
|
||||
let result = ide_host::ops::run::start(&host, "main", config, Arc::new(|_| {})).await;
|
||||
let error = result.err().map(|e| e.message);
|
||||
let _ = host.mcp_requests.answer("main", id, error.is_none(), error);
|
||||
}
|
||||
});
|
||||
started
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn agent_process(h: &Harness, name: &str) -> ide_core::RunId {
|
||||
let id = format!("mcp-agent:{name}");
|
||||
h.host.processes.list().into_iter().filter(|r| r.config_id == id && r.running).map(|r| r.id).max().unwrap()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_commands_run_in_named_tabs_and_report_what_the_user_did() {
|
||||
let h = Harness::new();
|
||||
h.trust().await;
|
||||
h.host.mcp_requests.set_ready("main", true);
|
||||
h.host
|
||||
.global_settings
|
||||
.set(ide_workspace::SettingsScope::Global, "mcpServer.runWithoutConfirmation", json!(true))
|
||||
.unwrap();
|
||||
let started = run_tool_window(&h);
|
||||
|
||||
// A command that finishes within the wait, in a tab named with the prefix.
|
||||
let done = h.json("run_command", json!({"name": "check", "command": "echo hel''lo; exit 3"})).await;
|
||||
assert_eq!(done["name"], "_agent_check", "{done}");
|
||||
assert_eq!(done["state"], "failed", "{done}");
|
||||
assert_eq!(done["exitCode"], 3);
|
||||
assert_eq!(done["startedBy"], "agent");
|
||||
assert!(done["output"].as_str().unwrap().contains("hello"), "{done}");
|
||||
assert!(done.get("interventions").is_none());
|
||||
|
||||
// The finished name runs the next command; a running one is refused, never stopped.
|
||||
let running =
|
||||
h.json("run_command", json!({"name": "_agent_check", "command": "echo ready; sleep 30", "timeout": 0})).await;
|
||||
assert_eq!(running["state"], "running", "{running}");
|
||||
let matched = h.json("wait_for_run", json!({"name": "check", "pattern": "(?m)^ready", "timeout": 10000})).await;
|
||||
assert_eq!(matched["waitEnded"], "matched", "{matched}");
|
||||
let refused = h.error("run_command", json!({"name": "check", "command": "true"})).await;
|
||||
assert!(refused.contains("still running `echo ready; sleep 30`"), "{refused}");
|
||||
assert!(refused.contains("Last output:\nready"), "the refusal quotes the last output: {refused}");
|
||||
|
||||
// What the user types (a line from the Run console's input) ends a wait at once, with advice for the agent.
|
||||
let process = agent_process(&h, "_agent_check");
|
||||
ide_host::ops::run::stdin(&h.host, process, "yes\n").unwrap();
|
||||
let typed = h.json("wait_for_run", json!({"name": "check", "timeout": 10000})).await;
|
||||
assert_eq!(typed["waitEnded"], "userIntervened", "{typed}");
|
||||
assert_eq!(typed["interventions"][0]["message"], "User input: yes");
|
||||
assert!(typed["advice"].as_str().unwrap().contains("Do not stop the process"));
|
||||
let input = h.json("send_run_input", json!({"name": "check", "text": "abc", "keys": ["Enter"]})).await;
|
||||
assert!(input.get("interventions").is_none(), "the agent's own input is no intervention: {input}");
|
||||
|
||||
let stopped = h.json("stop_run", json!({"name": "check", "force": true, "timeout": 10000})).await;
|
||||
assert_eq!(stopped["state"], "stoppedByAgent", "{stopped}");
|
||||
assert!(stopped.get("interventions").is_none());
|
||||
|
||||
// The user's Stop, rerun and closing the tab are reported with their time.
|
||||
h.json("run_command", json!({"name": "server", "command": "sleep 30", "timeout": 0})).await;
|
||||
ide_host::ops::run::stop(&h.host, "main", agent_process(&h, "_agent_server")).unwrap();
|
||||
let stop = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
|
||||
assert!(stop["interventions"][0]["message"].as_str().unwrap().starts_with("The user stopped this command at"));
|
||||
let ended = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
|
||||
assert_eq!(ended["state"], "stoppedByUser", "{ended}");
|
||||
|
||||
let config = started.lock().unwrap().last().cloned().unwrap();
|
||||
ide_host::ops::run::start(&h.host, "main", config, Arc::new(|_| {})).await.unwrap();
|
||||
let status = h.json("get_run_status", json!({"name": "server"})).await;
|
||||
assert_eq!(status["startedBy"], "user", "{status}");
|
||||
assert!(status["interventions"][0]["message"].as_str().unwrap().starts_with("The user reran this command at"));
|
||||
h.json("stop_run", json!({"name": "server", "force": true, "timeout": 10000})).await;
|
||||
h.host.mcp_agent_runs.closed(&h.root, "_agent_server");
|
||||
let closed = h.json("get_run_status", json!({"name": "server"})).await;
|
||||
assert!(closed["tabClosedAt"].is_string(), "{closed}");
|
||||
assert!(closed["interventions"][0]["message"].as_str().unwrap().starts_with("The user closed this tab at"));
|
||||
|
||||
let log = h.json("read_run_output", json!({"name": "check", "maxLinesCount": 20})).await;
|
||||
assert!(log["output"].as_str().unwrap().contains("ready"), "{log}");
|
||||
let from_start = h.json("read_run_output", json!({"name": "check", "offset": 0})).await;
|
||||
assert!(from_start["output"].as_str().unwrap().contains("hello"), "one log across the tab's commands");
|
||||
let runs = h.json("list_runs", json!({})).await;
|
||||
assert_eq!(runs["runs"].as_array().unwrap().len(), 2, "{runs}");
|
||||
let missing = h.error("get_run_status", json!({"name": "nothing"})).await;
|
||||
assert!(missing.contains("no agent tab named `_agent_nothing`"), "{missing}");
|
||||
|
||||
// Pipes: plain lines, cleaned of escapes and cut to maxLinesCount (the end stays by default).
|
||||
let printf = "printf '\\033[31mone\\033[0m\\ntwo\\nthree\\n'";
|
||||
let lines =
|
||||
h.json("run_command", json!({"name": "lines", "command": printf, "maxLinesCount": 2, "timeout": 10000})).await;
|
||||
assert_eq!(lines["output"], "<<<...content truncated...>>>\ntwo\nthree\n", "{lines}");
|
||||
assert_eq!(lines["truncatedLines"], 1);
|
||||
let head = h.json("read_run_output", json!({"name": "lines", "maxLinesCount": 1, "truncateMode": "END"})).await;
|
||||
assert_eq!(head["output"], "one\n<<<...content truncated...>>>\n", "{head}");
|
||||
|
||||
// Ctrl+C stops the command as stop_run does; terminal keys have no meaning on pipes.
|
||||
h.json("run_command", json!({"name": "interrupt", "command": "sleep 30", "timeout": 0})).await;
|
||||
let key = h.error("send_run_input", json!({"name": "interrupt", "keys": ["Up"]})).await;
|
||||
assert!(key.contains("runs on pipes"), "{key}");
|
||||
h.json("send_run_input", json!({"name": "interrupt", "keys": ["Ctrl+C"]})).await;
|
||||
let interrupted = h.json("wait_for_run", json!({"name": "interrupt", "timeout": 10000})).await;
|
||||
assert_eq!(interrupted["state"], "stoppedByAgent", "{interrupted}");
|
||||
}
|
||||
@@ -6352,26 +6352,6 @@ fn api14_stage3_configurations_test_results_and_content_actions_use_real_exports
|
||||
assert!(f.host.parse_test_results("main", "plugin:execution:dynamic", "trap".into()).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_plugin_command_opens_a_diff_in_its_window() {
|
||||
let f = execution_fixture();
|
||||
f.take_events();
|
||||
f.run("execution", "diff", None).unwrap();
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(eventually(|| {
|
||||
f.events.lock().iter().any(|(client, event)| {
|
||||
client.as_deref() == Some("main")
|
||||
&& matches!(event, PluginEvent::OpenDiff { plugin_id, diff, file_name }
|
||||
if plugin_id == "execution"
|
||||
&& diff.left == "before\n"
|
||||
&& diff.right == "after\n"
|
||||
&& diff.title.as_deref() == Some("Review")
|
||||
&& file_name.as_deref() == Some("src/review.rs"))
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_fixture_exports_test_selection() {
|
||||
let f = fixture(Limits::default());
|
||||
@@ -6702,11 +6682,7 @@ fn project_write_cannot_replace_the_git_file_of_a_linked_worktree() {
|
||||
}
|
||||
for rel in spellings {
|
||||
let err = f.run("misbehave", "projwrite", Some(rel)).unwrap_err();
|
||||
// On Windows a stream name is refused before the path is looked at at all; elsewhere a colon
|
||||
// is an ordinary character and the `.git` rule applies.
|
||||
let refused =
|
||||
if cfg!(windows) && rel.contains(':') { "file streams are not allowed" } else { "git's own files" };
|
||||
assert!(err.contains(refused), "{rel}: {err}");
|
||||
assert!(err.contains("git's own files"), "{rel}: {err}");
|
||||
assert_eq!(std::fs::read_to_string(&pointer).unwrap(), gitdir, "{rel}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,77 +252,6 @@ async fn maven_sync() {
|
||||
assert!(!junit.children.is_empty(), "{junit:?}");
|
||||
}
|
||||
|
||||
/// The launch classpath must come from the runner JDK, not the JDK that happened
|
||||
/// to load the language-server model: JDK-activated profiles can select a
|
||||
/// different dependency classifier. Use real Maven so this covers the scenario
|
||||
/// rather than only the probe arguments.
|
||||
#[test]
|
||||
#[ignore = "project smoke: needs Maven and a JDK"]
|
||||
fn maven_launch_classpath_uses_the_runner_jdk_classifier() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let java_home = PathBuf::from(std::env::var("JAVA_HOME").expect("JAVA_HOME"));
|
||||
let release = std::fs::read_to_string(java_home.join("release")).unwrap();
|
||||
let version = release
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("JAVA_VERSION=\""))
|
||||
.and_then(|line| line.strip_suffix('"'))
|
||||
.expect("JAVA_VERSION in release");
|
||||
let major = version.strip_prefix("1.").unwrap_or(version).split(['.', '_']).next().unwrap().parse::<u32>().unwrap();
|
||||
let jar = java_home.join(if cfg!(windows) { "bin/jar.exe" } else { "bin/jar" });
|
||||
let jars = root.join("libs");
|
||||
let staging = root.join("empty");
|
||||
std::fs::create_dir_all(&jars).unwrap();
|
||||
std::fs::create_dir_all(&staging).unwrap();
|
||||
for name in ["api-jdk8.jar", "api-jdk9.jar"] {
|
||||
let status = std::process::Command::new(&jar)
|
||||
.args(["cf"])
|
||||
.arg(jars.join(name))
|
||||
.arg(".")
|
||||
.current_dir(&staging)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
}
|
||||
std::fs::write(
|
||||
root.join("pom.xml"),
|
||||
r#"<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>demo</groupId><artifactId>jdk-classifier</artifactId><version>1</version>
|
||||
<profiles>
|
||||
<profile><id>jdk8</id><activation><jdk>1.8</jdk></activation><dependencies>
|
||||
<dependency><groupId>demo</groupId><artifactId>api</artifactId><version>1</version><classifier>jdk8</classifier>
|
||||
<scope>system</scope><systemPath>${project.basedir}/libs/api-jdk8.jar</systemPath></dependency>
|
||||
</dependencies></profile>
|
||||
<profile><id>jdk9</id><activation><jdk>[9,)</jdk></activation><dependencies>
|
||||
<dependency><groupId>demo</groupId><artifactId>api</artifactId><version>1</version><classifier>jdk9</classifier>
|
||||
<scope>system</scope><systemPath>${project.basedir}/libs/api-jdk9.jar</systemPath></dependency>
|
||||
</dependencies></profile>
|
||||
</profiles>
|
||||
</project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cache = root.join("cache");
|
||||
let mut model = ide_build::load(root, &cache, None);
|
||||
for project in &mut model.maven.as_mut().unwrap().projects {
|
||||
project.env.insert("JAVA_HOME".into(), java_home.to_string_lossy().into_owned());
|
||||
}
|
||||
let classpath = ide_build::compile::module_classpath(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
false,
|
||||
None,
|
||||
&root.join("classpath-cache"),
|
||||
&ide_core::CancellationToken::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let expected = if major <= 8 { "api-jdk8.jar" } else { "api-jdk9.jar" };
|
||||
let other = if major <= 8 { "api-jdk9.jar" } else { "api-jdk8.jar" };
|
||||
assert!(classpath.contains(expected), "{classpath}");
|
||||
assert!(!classpath.contains(other), "{classpath}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs Gradle and a JDK"]
|
||||
async fn gradle_sync() {
|
||||
|
||||
@@ -455,29 +455,3 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
|
||||
invoke(&peer, "close_project", json!({})).await.unwrap();
|
||||
peer.kill();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn the_desktop_routes_exactly_what_a_server_of_this_build_answers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path().join("server");
|
||||
let peer_slot = Arc::new(OnceLock::new());
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
// As `openidea-server` assembles its commands.
|
||||
let plugins = ide_host::plugins::remote_host::RemotePlugins::start(&base.join("data"), &host, peer_slot).unwrap();
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
plugins.register(&mut dispatcher);
|
||||
dispatcher.add(ide_host::plugins::remote::SYNC_COMMAND, |_| async { Ok(()) });
|
||||
assert_eq!(rpc::client_commands(), dispatcher.commands());
|
||||
assert!(rpc::client_commands().contains(&"term_spawn"));
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
@@ -83,10 +83,10 @@ async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_in
|
||||
};
|
||||
let original_metadata = "fileFormatVersion: 2\nguid: 11111111111111111111111111111111\n";
|
||||
std::fs::write(meta(&original), original_metadata).unwrap();
|
||||
ops::fs::rename(&host, "main", original.clone(), renamed.clone()).await.unwrap();
|
||||
ops::fs::rename(&host, original.clone(), renamed.clone()).await.unwrap();
|
||||
assert!(!original.exists() && !meta(&original).exists());
|
||||
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
|
||||
ops::fs::copy(&host, "main", renamed.clone(), copied.clone()).await.unwrap();
|
||||
ops::fs::copy(&host, renamed.clone(), copied.clone()).await.unwrap();
|
||||
assert!(copied.exists());
|
||||
assert_ne!(std::fs::read_to_string(meta(&copied)).unwrap(), original_metadata);
|
||||
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
|
||||
|
||||
@@ -21,9 +21,6 @@ ignore.workspace = true
|
||||
globset.workspace = true
|
||||
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
|
||||
|
||||
[build-dependencies]
|
||||
blake3.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
criterion = { version = "0.5", default-features = false }
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
//! Hashes this crate's sources into `INDEX_SOURCE_HASH`: what is extracted and stored for a
|
||||
//! file (ignored state, size limit, record layout) is decided here as well as in
|
||||
//! `ide-language`, so the persistent index is rebuilt when either changes.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
let src = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("src");
|
||||
println!("cargo:rerun-if-changed={}", src.display());
|
||||
let mut files = Vec::new();
|
||||
collect(&src, &mut files);
|
||||
files.sort();
|
||||
let mut h = blake3::Hasher::new();
|
||||
for file in files {
|
||||
h.update(file.strip_prefix(&src).unwrap_or(&file).to_string_lossy().replace('\\', "/").as_bytes());
|
||||
h.update(b"\0");
|
||||
h.update(&std::fs::read(&file).unwrap());
|
||||
h.update(b"\0");
|
||||
}
|
||||
println!("cargo:rustc-env=INDEX_SOURCE_HASH={}", &h.finalize().to_hex()[..16]);
|
||||
}
|
||||
|
||||
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect(&path, out);
|
||||
} else {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ impl Store {
|
||||
}
|
||||
};
|
||||
let store = Self { db: RwLock::new(db) };
|
||||
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
|
||||
let stamp = format!("{SCHEMA}\n{fingerprint}\n{}", root.display());
|
||||
match store.meta("stamp") {
|
||||
Ok(Some(v)) if v == stamp.as_bytes() => {}
|
||||
_ => {
|
||||
|
||||
@@ -58,9 +58,6 @@ tree-sitter-graphql = "0.3.0"
|
||||
tree-sitter-prisma-io = "1.6.0"
|
||||
tree-sitter-gdscript = "6.1.0"
|
||||
|
||||
[build-dependencies]
|
||||
blake3.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", default-features = false }
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
//! Hashes everything that decides what `Syntax::tags` extracts (this crate's
|
||||
//! sources, queries, language definitions and the locked tree-sitter crates)
|
||||
//! into `SYNTAX_SOURCE_HASH`, so persistent caches survive releases that leave
|
||||
//! extraction untouched.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
let workspace = manifest.join("../..");
|
||||
let lock = workspace.join("Cargo.lock");
|
||||
let inputs = [manifest.join("src"), manifest.join("queries"), workspace.join("languages")];
|
||||
|
||||
let mut h = blake3::Hasher::new();
|
||||
for dir in &inputs {
|
||||
println!("cargo:rerun-if-changed={}", dir.display());
|
||||
let mut files = Vec::new();
|
||||
collect(dir, &mut files);
|
||||
files.sort();
|
||||
for file in files {
|
||||
let rel = file.strip_prefix(&workspace).unwrap_or(&file);
|
||||
h.update(rel.to_string_lossy().replace('\\', "/").as_bytes());
|
||||
h.update(b"\0");
|
||||
h.update(&std::fs::read(&file).unwrap());
|
||||
h.update(b"\0");
|
||||
}
|
||||
}
|
||||
println!("cargo:rerun-if-changed={}", lock.display());
|
||||
match std::fs::read_to_string(&lock) {
|
||||
Ok(text) => h.update(tree_sitter_packages(&text).as_bytes()),
|
||||
// Built outside the workspace: fall back to the crate version.
|
||||
Err(_) => h.update(std::env::var("CARGO_PKG_VERSION").unwrap().as_bytes()),
|
||||
};
|
||||
println!("cargo:rustc-env=SYNTAX_SOURCE_HASH={}", &h.finalize().to_hex()[..16]);
|
||||
}
|
||||
|
||||
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect(&path, out);
|
||||
} else {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `name version checksum` of every locked `tree-sitter*` package.
|
||||
fn tree_sitter_packages(lock: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for package in lock.split("[[package]]") {
|
||||
let field = |key: &str| {
|
||||
package.lines().find_map(|l| l.strip_prefix(key)?.strip_prefix(" = ")).unwrap_or("").trim_matches('"')
|
||||
};
|
||||
if field("name").starts_with("tree-sitter") {
|
||||
out.push_str(&format!("{} {} {}\n", field("name"), field("version"), field("checksum")));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -132,11 +132,29 @@ impl Syntax {
|
||||
SPECS.iter().map(|s| s.name)
|
||||
}
|
||||
|
||||
/// Changes whenever extraction output may change (this crate's sources,
|
||||
/// queries, language definitions, tree-sitter crate versions; see
|
||||
/// `build.rs`); persistent caches of [`Syntax::tags`] key on it.
|
||||
/// Changes whenever extraction output may change (grammar set, queries,
|
||||
/// crate version); persistent caches of [`Syntax::tags`] key on it.
|
||||
pub fn fingerprint(&self) -> String {
|
||||
env!("SYNTAX_SOURCE_HASH").to_owned()
|
||||
static FP: OnceLock<String> = OnceLock::new();
|
||||
FP.get_or_init(|| {
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(env!("CARGO_PKG_VERSION").as_bytes());
|
||||
// Bump when web definitions (`.class`, `#id`, `--var` tags) change.
|
||||
h.update(b"web-definitions-1");
|
||||
for s in SPECS {
|
||||
h.update(s.name.as_bytes());
|
||||
h.update(&Language::new(s.language).abi_version().to_le_bytes());
|
||||
h.update(s.outline.as_bytes());
|
||||
for list in [s.fold_nodes, s.fold_bodies, s.imports, s.constructors] {
|
||||
for k in list {
|
||||
h.update(k.as_bytes());
|
||||
h.update(b"\0");
|
||||
}
|
||||
}
|
||||
}
|
||||
h.finalize().to_hex()[..16].to_owned()
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Whether a tree-sitter grammar is bundled for this language.
|
||||
|
||||
@@ -223,8 +223,6 @@ impl ClientHandler {
|
||||
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
|
||||
return;
|
||||
}
|
||||
let mut params = params;
|
||||
crate::virtual_doc::normalize_uris(&mut params);
|
||||
let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
@@ -252,14 +250,7 @@ impl ClientHandler {
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A late answer for an older version of the text is shown, but isn't fresh (`check_files`).
|
||||
let obsolete =
|
||||
params.version.is_some_and(|v| mgr.docs.lock().values().any(|d| d.path == path && d.version > v));
|
||||
let shown = if obsolete {
|
||||
self.server.set_obsolete_diagnostics(path.clone(), params.diagnostics)
|
||||
} else {
|
||||
self.server.set_diagnostics(path.clone(), params.diagnostics)
|
||||
};
|
||||
let shown = self.server.set_diagnostics(path.clone(), params.diagnostics);
|
||||
mgr.emit_diagnostics(&self.server, path, shown, self.encoding());
|
||||
}
|
||||
|
||||
@@ -324,13 +315,11 @@ impl ClientHandler {
|
||||
}
|
||||
|
||||
fn apply_edit(&self, mgr: &LspManager, params: Value) -> Result<Value, (i64, String)> {
|
||||
let edit: lsp::WorkspaceEdit =
|
||||
params.get("edit").cloned().ok_or_else(|| (codes::INVALID_PARAMS, "missing edit".to_owned())).and_then(
|
||||
|mut e| {
|
||||
crate::virtual_doc::normalize_uris(&mut e);
|
||||
serde_json::from_value(e).map_err(|e| (codes::INVALID_PARAMS, e.to_string()))
|
||||
},
|
||||
)?;
|
||||
let edit: lsp::WorkspaceEdit = params
|
||||
.get("edit")
|
||||
.cloned()
|
||||
.ok_or_else(|| (codes::INVALID_PARAMS, "missing edit".to_owned()))
|
||||
.and_then(|e| serde_json::from_value(e).map_err(|e| (codes::INVALID_PARAMS, e.to_string())))?;
|
||||
// Convert before calling the hook: it re-enters document sync.
|
||||
let edit = convert::workspace_edit(&mgr.conv(self.encoding()), edit);
|
||||
let applied = match mgr.apply_edit_hook() {
|
||||
|
||||
@@ -22,9 +22,6 @@ use std::time::{Duration, Instant};
|
||||
/// How long Inspect Code waits for a server that is still starting (jdtls,
|
||||
/// rust-analyzer on a large workspace).
|
||||
const INSPECT_START_WAIT: Duration = Duration::from_secs(60);
|
||||
/// Quiet period after a publish before a checked file counts as done (follow-up publishes:
|
||||
/// syntax first, semantics later).
|
||||
const CHECK_SETTLE: Duration = Duration::from_millis(500);
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct InspectOptions {
|
||||
@@ -51,29 +48,6 @@ pub trait InspectHost: Send + Sync + 'static {
|
||||
fn close(&self, doc: DocId);
|
||||
}
|
||||
|
||||
/// One language server's part in a [`DocumentCheck`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CheckedServer {
|
||||
pub name: String,
|
||||
/// Its state when the check ended: not `Ready`/`Busy` means it could not check.
|
||||
pub status: ide_core::event::ServerStatus,
|
||||
/// Why it isn't running (not installed, misconfigured, crashed), as the status bar says.
|
||||
pub message: Option<String>,
|
||||
/// Its diagnostics are in the result.
|
||||
pub checked: bool,
|
||||
/// It was still analysing (or had not published for this change) when the wait ended.
|
||||
pub incomplete: bool,
|
||||
/// Asking it failed.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Fresh diagnostics of a document, with every language server that serves it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DocumentCheck {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub servers: Vec<CheckedServer>,
|
||||
}
|
||||
|
||||
/// What became of one file.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FileInspection {
|
||||
@@ -224,99 +198,6 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fresh diagnostics of the open document `doc`, whose text the caller just synced
|
||||
/// (`changed`: at that instant, so diagnostics published before it are stale). Tells the
|
||||
/// servers the document was saved, since checks that run on save (rust-analyzer's
|
||||
/// `cargo check`) don't run for a change made outside the IDE, then waits for each server
|
||||
/// to be ready, to answer for this text and to finish the background work it started, up
|
||||
/// to `timeout`. Every server serving the document is reported, so a caller can tell "no
|
||||
/// problems" from "nothing could check".
|
||||
pub async fn check_document(
|
||||
&self,
|
||||
doc: DocId,
|
||||
changed: Option<Instant>,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<DocumentCheck> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
// The text may have changed before this call synced it (the file watcher reloaded it):
|
||||
// only diagnostics published after the servers got the current text are fresh.
|
||||
let changed = Some(changed.map_or(snap.changed, |c| c.max(snap.changed)));
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
let left = || deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
let ready = tokio::select! {
|
||||
r = futures::future::join_all(snap.servers.iter().map(|s| s.wait_ready(left()))) => r,
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
};
|
||||
// Once the servers have the document: a save sent while one starts would not reach it.
|
||||
self.did_save(doc, snap.text.as_str());
|
||||
let per_server = snap.servers.iter().zip(ready).map(|(server, run)| async {
|
||||
let mut report = CheckedServer {
|
||||
name: server.name.clone(),
|
||||
status: server.status(),
|
||||
message: server.status_message(),
|
||||
checked: false,
|
||||
incomplete: false,
|
||||
error: None,
|
||||
};
|
||||
let Some(run) = run else { return Some((report, Vec::new())) };
|
||||
let t = Target { server: server.clone(), run };
|
||||
if !t.caps().restriction.diagnostics() {
|
||||
return None;
|
||||
}
|
||||
let pull = t.caps().has("diagnosticProvider");
|
||||
let mut since = changed;
|
||||
let raw = loop {
|
||||
// A pull asks for this text; a push server is waited on to publish for it.
|
||||
let raw = if pull {
|
||||
server.wait_idle(left(), cancel).await;
|
||||
self.pull_diagnostics(&t, &snap.uri, cancel, left()).await
|
||||
} else {
|
||||
let opts = InspectOptions { concurrency: 1, timeout: left(), settle: CHECK_SETTLE };
|
||||
wait_for_publish(server, &snap.path, since, opts, cancel).await
|
||||
};
|
||||
// The save may have started work (a `cargo check`) that publishes when it ends.
|
||||
if raw.is_err() || pull || !server.is_busy() || left().is_zero() {
|
||||
break raw;
|
||||
}
|
||||
if !server.wait_idle(left(), cancel).await {
|
||||
break raw.map(|_| server.diagnostics_for(&snap.path));
|
||||
}
|
||||
// Idle: its last publish is final once quiet.
|
||||
since = None;
|
||||
};
|
||||
report.status = server.status();
|
||||
match raw {
|
||||
Ok(items) => {
|
||||
report.checked = true;
|
||||
let fresh =
|
||||
pull || changed.is_none_or(|c| server.last_published(&snap.path).is_some_and(|p| p >= c));
|
||||
report.incomplete = !fresh || server.is_busy();
|
||||
let conv = self.conv(t.enc());
|
||||
let items = items
|
||||
.into_iter()
|
||||
.map(|d| convert::diagnostic(&conv, &snap.path, d, &t.server.name))
|
||||
.collect::<Vec<_>>();
|
||||
Some((report, items))
|
||||
}
|
||||
Err(e) => {
|
||||
report.error = Some(e.message);
|
||||
Some((report, Vec::new()))
|
||||
}
|
||||
}
|
||||
});
|
||||
let results = tokio::select! {
|
||||
r = futures::future::join_all(per_server) => r,
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
};
|
||||
let mut out = DocumentCheck::default();
|
||||
for (report, items) in results.into_iter().flatten() {
|
||||
out.servers.push(report);
|
||||
out.diagnostics.extend(items);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn pull_diagnostics(
|
||||
&self,
|
||||
t: &Target,
|
||||
|
||||
+13
-33
@@ -40,7 +40,7 @@ mod vue_navigation;
|
||||
mod vue_refs;
|
||||
|
||||
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
|
||||
pub use inspect::{CheckedServer, DocumentCheck, FileInspection, InspectHost, InspectOptions};
|
||||
pub use inspect::{FileInspection, InspectHost, InspectOptions};
|
||||
pub use jdtls::jvm_inspect::JdtJvmInspector;
|
||||
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
|
||||
pub use launch::{LaunchError, Launcher, ProcessLauncher};
|
||||
@@ -120,8 +120,6 @@ struct DocState {
|
||||
compose_detected: bool,
|
||||
/// The notebook a code cell belongs to: synced through `notebookDocument/*`, not as a file.
|
||||
notebook: Option<PathBuf>,
|
||||
/// When the servers last got new text (open or change): diagnostics published before are stale.
|
||||
changed: std::time::Instant,
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
@@ -160,8 +158,6 @@ struct DocSnap {
|
||||
language: LanguageId,
|
||||
text: Arc<DocText>,
|
||||
servers: Vec<Arc<Server>>,
|
||||
/// [`DocState::changed`].
|
||||
changed: std::time::Instant,
|
||||
}
|
||||
|
||||
impl DocSnap {
|
||||
@@ -382,32 +378,15 @@ fn read_jar_source(uri: &str, encoding: Option<&str>) -> Option<(String, ide_vfs
|
||||
/// Library classes in `jar:` targets (the official Kotlin LSP) go to their source
|
||||
/// in the artifact's sources jar when it is cached, at the declaration of the
|
||||
/// name navigated from; [`LspManager::virtual_document`] serves it.
|
||||
async fn library_sources(snap: &DocSnap, root: &Path, pos: Pos, locs: Vec<Location>) -> Vec<Location> {
|
||||
let jar_class = |u: &str| u.starts_with("jar:") && u.ends_with(".class");
|
||||
// The path names the class's source file (`Db.kt`); the handle in the query, the class.
|
||||
let jdt_class = |u: &str| u.starts_with("jdt://contents/") && u.contains(".class");
|
||||
if !locs.iter().any(|l| l.uri.as_deref().is_some_and(|u| jar_class(u) || jdt_class(u))) {
|
||||
async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<Location> {
|
||||
if !locs.iter().any(|l| l.uri.as_deref().is_some_and(|u| u.starts_with("jar:") && u.ends_with(".class"))) {
|
||||
return locs;
|
||||
}
|
||||
let name = library_source::identifier_at(snap.text.line(pos.line as usize), pos.col).map(str::to_owned);
|
||||
let root = root.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
locs.into_iter()
|
||||
.map(|loc| {
|
||||
// A class JDT finds in a project's class folder (Kotlin compiled for Java code):
|
||||
// the project's source file, not the decompiled class.
|
||||
if let Some(uri) = loc.uri.as_deref().filter(|u| jdt_class(u)) {
|
||||
let source = library_source::folder_class(uri)
|
||||
.and_then(|(package, class)| library_source::project_class_source(&root, &package, &class));
|
||||
let Some(source) = source else { return loc };
|
||||
let range = name
|
||||
.as_deref()
|
||||
.zip(std::fs::read_to_string(&source).ok())
|
||||
.map(|(n, text)| library_source::declaration_range(&text, n))
|
||||
.unwrap_or_default();
|
||||
return Location { path: source.into(), range, uri: None };
|
||||
}
|
||||
let Some(uri) = loc.uri.as_deref().filter(|u| jar_class(u)) else {
|
||||
let Some(uri) = loc.uri.as_deref().filter(|u| u.starts_with("jar:") && u.ends_with(".class")) else {
|
||||
return loc;
|
||||
};
|
||||
let Some((source, loaded)) = library_source::class_source(uri, None) else { return loc };
|
||||
@@ -1174,7 +1153,6 @@ impl LspManager {
|
||||
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
|
||||
compose_detected,
|
||||
notebook: None,
|
||||
changed: std::time::Instant::now(),
|
||||
};
|
||||
let compose_added = {
|
||||
let mut docs = self.docs.lock();
|
||||
@@ -1222,7 +1200,6 @@ impl LspManager {
|
||||
let mut docs = self.docs.lock();
|
||||
let Some(d) = docs.get_mut(&doc) else { return };
|
||||
d.version = version as i32;
|
||||
d.changed = std::time::Instant::now();
|
||||
|
||||
// Servers that have this document open on their current instance.
|
||||
let targets: Vec<(Arc<Server>, Running, SyncKind, Encoding)> = d
|
||||
@@ -1398,7 +1375,6 @@ impl LspManager {
|
||||
language: d.language.clone(),
|
||||
text: d.text.clone(),
|
||||
servers,
|
||||
changed: d.changed,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1440,9 +1416,13 @@ impl LspManager {
|
||||
let params = serde_json::to_value(params).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let stamp = self.injected_request_stamp(¶ms);
|
||||
let fut = async {
|
||||
let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?;
|
||||
virtual_doc::normalize_uris(&mut value);
|
||||
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
|
||||
if t.server.name == "csharp-ls" {
|
||||
let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?;
|
||||
virtual_doc::normalize_csharp_uris(&mut value);
|
||||
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
|
||||
} else {
|
||||
t.run.peer.request::<Value, R>(method, params, cancel.cloned()).await
|
||||
}
|
||||
};
|
||||
let result = match tokio::time::timeout(timeout, fut).await {
|
||||
Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)),
|
||||
@@ -1703,7 +1683,7 @@ impl LspManager {
|
||||
Ok(found) => {
|
||||
let locs =
|
||||
convert::goto_response(&self.conv(t.enc()), lsp::GotoDefinitionResponse::Array(found.locations));
|
||||
let locs = library_sources(snap, &self.project_root, pos, locs).await;
|
||||
let locs = library_sources(snap, pos, locs).await;
|
||||
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
|
||||
(locs, found.complete)
|
||||
}
|
||||
@@ -1752,7 +1732,7 @@ impl LspManager {
|
||||
match r {
|
||||
Ok(Some(r)) => {
|
||||
let locs = convert::goto_response(&self.conv(t.enc()), r);
|
||||
let locs = library_sources(snap, &self.project_root, pos, locs).await;
|
||||
let locs = library_sources(snap, pos, locs).await;
|
||||
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
|
||||
self.note_arduino_sources(&t.server, &locs);
|
||||
let locs = merge_locations(first, locs);
|
||||
|
||||
@@ -226,66 +226,6 @@ pub(crate) fn identifier_at(line: &str, col: u32) -> Option<&str> {
|
||||
(!word.is_empty() && !word.starts_with(|c: char| c.is_ascii_digit())).then_some(word)
|
||||
}
|
||||
|
||||
/// A class of a class folder on JDT's classpath, as `(package path, class name)`, read
|
||||
/// from the element handle in the query:
|
||||
/// `jdt://contents/<folder>/<pkg>/<File>?=<project>/<escaped folder>[=/<attribute>=/<value>]…<pkg(Name.class`
|
||||
/// (`<File>` is the class's SourceFile, `Db.kt` for a Kotlin class). A folder is a project's
|
||||
/// own build output (the Kotlin classes the IDE compiles for JDT among them), never a
|
||||
/// dependency, which is a jar or a JDK module.
|
||||
pub(crate) fn folder_class(uri: &str) -> Option<(String, String)> {
|
||||
uri.strip_prefix("jdt://contents/")?;
|
||||
let query = percent_decode_str(uri.split_once('?')?.1).decode_utf8().ok()?;
|
||||
let (root, element) = query.split_once('/')?.1.split_once('<')?;
|
||||
let folder = PathBuf::from(root.split("=/").next()?.replace('\\', ""));
|
||||
let (package, file) = element.split_once('(')?;
|
||||
let name = file.strip_suffix(".class")?;
|
||||
if name.is_empty() || !folder.is_dir() {
|
||||
return None;
|
||||
}
|
||||
Some((package.replace('.', "/"), name.to_owned()))
|
||||
}
|
||||
|
||||
/// The project's source of `class` (`Name`, `Outer$Inner`, `FileKt`) in `package`
|
||||
/// (`/`-separated): a `.kt` / `.java` file of that name declaring the package. Build
|
||||
/// output and ignored files are skipped.
|
||||
pub(crate) fn project_class_source(root: &Path, package: &str, class: &str) -> Option<PathBuf> {
|
||||
let outer = class.split('$').next().unwrap_or(class);
|
||||
let mut names = vec![format!("{outer}.kt"), format!("{outer}.java")];
|
||||
if let Some(file) = outer.strip_suffix("Kt").filter(|f| !f.is_empty()) {
|
||||
names.push(format!("{file}.kt"));
|
||||
}
|
||||
let package = package.replace('/', ".");
|
||||
let mut found: Vec<PathBuf> = ignore::WalkBuilder::new(root)
|
||||
.filter_entry(|e| {
|
||||
let output = matches!(e.file_name().to_str(), Some("target" | "build" | "out" | "node_modules"));
|
||||
// A package may be named `build`: only outside source trees is it output.
|
||||
!output || e.path().components().any(|c| c.as_os_str() == "src")
|
||||
})
|
||||
.build()
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
|
||||
.filter(|e| e.file_name().to_str().is_some_and(|n| names.iter().any(|name| name == n)))
|
||||
.map(|e| e.into_path())
|
||||
.filter(|p| std::fs::read_to_string(p).is_ok_and(|text| declared_package(&text) == package))
|
||||
.collect();
|
||||
// The directory matching the package first, then by name order.
|
||||
found.sort_by_key(|p| {
|
||||
let dir = p.parent().map(|d| d.to_string_lossy().replace('\\', "/")).unwrap_or_default();
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
(!dir.ends_with(&format!("/{}", package.replace('.', "/"))), names.iter().position(|n| n == name))
|
||||
});
|
||||
found.into_iter().next()
|
||||
}
|
||||
|
||||
/// The `package` a Kotlin or Java file declares; empty for the default package.
|
||||
fn declared_package(text: &str) -> String {
|
||||
text.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| line.starts_with("package "))
|
||||
.map(|line| line["package ".len()..].trim_end_matches(';').trim().replace('`', ""))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -474,58 +414,6 @@ mod tests {
|
||||
assert_eq!((reloaded.text.as_str(), reloaded.encoding.as_str()), (GBK_SAMPLE, "gbk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_class_folder_classes_to_project_sources() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let output = root.join(".cache/kotlin/main-0");
|
||||
std::fs::create_dir_all(output.join("test001")).unwrap();
|
||||
// As jdtls writes it: the class's source file name in the path, the library's
|
||||
// classpath attributes after the folder.
|
||||
let escaped = output.to_string_lossy().replace('/', "\\/");
|
||||
let handle = format!("=demo/{escaped}=/ai.openidea.kotlin=/true=/<test001(Db.class");
|
||||
let uri = format!("jdt://contents/main-0/test001/Db.kt?{}", utf8_percent_encode(&handle, PATH));
|
||||
assert_eq!(folder_class(&uri), Some(("test001".into(), "Db".into())));
|
||||
let plain = format!(
|
||||
"jdt://contents/main-0/test001/Db.class?{}",
|
||||
utf8_percent_encode(&format!("=demo/{escaped}<test001(Db.class"), PATH)
|
||||
);
|
||||
assert_eq!(folder_class(&plain), Some(("test001".into(), "Db".into())));
|
||||
let nested = format!(
|
||||
"jdt://contents/main-0/a.b/Db.kt?{}",
|
||||
utf8_percent_encode(&format!("=demo/{escaped}<a.b(Db$Companion.class"), PATH)
|
||||
);
|
||||
assert_eq!(folder_class(&nested), Some(("a/b".into(), "Db$Companion".into())));
|
||||
let jar = "jdt://contents/a.jar/p/C.class?=p/%5C/m2%5C/a.jar%3Cp%28C.class";
|
||||
assert_eq!(folder_class(jar), None, "a dependency jar");
|
||||
assert_eq!(folder_class(&uri.replace("main-0", "gone")), None);
|
||||
|
||||
let write = |path: &str, text: &str| {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
};
|
||||
write("src/main/kotlin/test001/Db.kt", "package test001\n\nclass Db\n");
|
||||
write("src/main/kotlin/other/Db.kt", "package other\n\nclass Db\n");
|
||||
write("target/generated/test001/Db.kt", "package test001\n\nclass Db\n");
|
||||
write("src/main/kotlin/util/Strings.kt", "@file:JvmName(\"Strings\")\npackage util\n\nfun shout() = 1\n");
|
||||
write("src/main/kotlin/com/x/build/Tool.kt", "package com.x.build\n\nclass Tool\n");
|
||||
write("src/main/kotlin/flat/Elsewhere.kt", "");
|
||||
write("src/main/kotlin/Loose.kt", "package flat\n\nobject Loose\n");
|
||||
let source = |package: &str, class: &str| {
|
||||
project_class_source(root, package, class).map(|p| p.strip_prefix(root).unwrap().to_owned())
|
||||
};
|
||||
assert_eq!(source("test001", "Db"), Some(PathBuf::from("src/main/kotlin/test001/Db.kt")));
|
||||
assert_eq!(source("test001", "Db$Companion"), Some(PathBuf::from("src/main/kotlin/test001/Db.kt")));
|
||||
assert_eq!(source("other", "Db"), Some(PathBuf::from("src/main/kotlin/other/Db.kt")));
|
||||
assert_eq!(source("util", "StringsKt"), Some(PathBuf::from("src/main/kotlin/util/Strings.kt")));
|
||||
assert_eq!(source("com/x/build", "Tool"), Some(PathBuf::from("src/main/kotlin/com/x/build/Tool.kt")));
|
||||
assert_eq!(source("flat", "Loose"), Some(PathBuf::from("src/main/kotlin/Loose.kt")), "any directory");
|
||||
assert_eq!(source("test001", "Missing"), None);
|
||||
assert_eq!(declared_package("package a.b.c;\n"), "a.b.c");
|
||||
assert_eq!(declared_package("class A"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locates_declarations_in_kotlin_and_java_sources() {
|
||||
let at = |text: &str, name: &str| {
|
||||
|
||||
@@ -213,7 +213,6 @@ impl LspManager {
|
||||
{
|
||||
if d.text.as_str() != cell.source {
|
||||
Arc::make_mut(&mut d.text).set(&cell.source);
|
||||
d.changed = std::time::Instant::now();
|
||||
}
|
||||
d.version += 1;
|
||||
state.cells.push((cell.id.clone(), doc));
|
||||
@@ -235,7 +234,6 @@ impl LspManager {
|
||||
servers: state.servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
|
||||
compose_detected: false,
|
||||
notebook: Some(path.to_owned()),
|
||||
changed: std::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
state.cells.push((cell.id.clone(), doc));
|
||||
@@ -261,7 +259,6 @@ impl LspManager {
|
||||
}
|
||||
Arc::make_mut(&mut d.text).set(&cell.source);
|
||||
d.version += 1;
|
||||
d.changed = std::time::Instant::now();
|
||||
content.push(json!({
|
||||
"document": { "uri": d.uri, "version": d.version },
|
||||
"changes": [{ "text": cell.source }],
|
||||
|
||||
@@ -658,24 +658,6 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until no `$/progress` task is active, background work included (jdtls' builds,
|
||||
/// rust-analyzer's `cargo check`), unlike [`Server::wait_indexed`]; false on timeout or
|
||||
/// cancellation.
|
||||
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let notified = self.phase_notify.notified();
|
||||
if !self.is_busy() || self.running().is_none() {
|
||||
return !self.is_busy();
|
||||
}
|
||||
tokio::select! {
|
||||
() = notified => {}
|
||||
() = tokio::time::sleep_until(deadline) => return !self.is_busy(),
|
||||
() = cancel.cancelled() => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- transiently opened files ----
|
||||
|
||||
/// Registers a holder of the transient file `path` on instance `generation`;
|
||||
@@ -770,20 +752,6 @@ impl Server {
|
||||
self.combine(path)
|
||||
}
|
||||
|
||||
/// [`Server::set_diagnostics`] for a publication tagged with a document version older than
|
||||
/// the text the server has now: shown, but not an answer for the current text, so
|
||||
/// [`Server::last_published`] keeps its time.
|
||||
pub fn set_obsolete_diagnostics(&self, path: PathBuf, items: Vec<lsp::Diagnostic>) -> Vec<lsp::Diagnostic> {
|
||||
let before = self.last_published(&path);
|
||||
let shown = self.set_diagnostics(path.clone(), items);
|
||||
let mut published = self.published.lock();
|
||||
match before {
|
||||
Some(at) => published.insert(path, at),
|
||||
None => published.remove(&path),
|
||||
};
|
||||
shown
|
||||
}
|
||||
|
||||
/// Records a pulled report for `path` (`None` items: unchanged since `result_id`'s
|
||||
/// predecessor); returns what the file now shows.
|
||||
pub fn set_pulled(
|
||||
|
||||
@@ -18,36 +18,23 @@ const URI_ESCAPES: &AsciiSet = &CONTROLS
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
/// Servers can leave spaces and Unicode unescaped in document URIs: csharp-ls uses .NET
|
||||
/// `Uri.ToString()`, and Java servers build some URIs with `java.net.URI.toString()` (which keeps
|
||||
/// non-ASCII characters), e.g. jdtls in a refactoring's edit for a project under a Chinese path.
|
||||
/// Escape them in document URI fields (and `WorkspaceEdit.changes` keys) before lsp-types' strict
|
||||
/// URI deserialization, which otherwise rejects the whole response.
|
||||
pub(crate) fn normalize_uris(value: &mut serde_json::Value) {
|
||||
/// csharp-ls uses .NET Uri.ToString(), which can leave spaces and Unicode unescaped.
|
||||
/// Normalize only document URI fields, before lsp-types' strict URI deserialization.
|
||||
pub(crate) fn normalize_csharp_uris(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_uris),
|
||||
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_csharp_uris),
|
||||
serde_json::Value::Object(fields) => {
|
||||
for (key, value) in fields.iter_mut() {
|
||||
for (key, value) in fields {
|
||||
// Opaque payloads must survive resolve/execute round trips unchanged.
|
||||
if matches!(key.as_str(), "data" | "arguments") {
|
||||
continue;
|
||||
}
|
||||
if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri")
|
||||
&& let Some(uri) = value.as_str().and_then(escaped_uri)
|
||||
if matches!(key.as_str(), "uri" | "targetUri")
|
||||
&& let Some(uri) = value.as_str().filter(|s| s.starts_with("csharp:"))
|
||||
{
|
||||
*value = uri.into();
|
||||
} else if key == "changes"
|
||||
&& let serde_json::Value::Object(changes) = value
|
||||
{
|
||||
*changes = std::mem::take(changes)
|
||||
.into_iter()
|
||||
.map(|(uri, mut edits)| {
|
||||
normalize_uris(&mut edits);
|
||||
(escaped_uri(&uri).unwrap_or(uri), edits)
|
||||
})
|
||||
.collect();
|
||||
*value = utf8_percent_encode(uri, URI_ESCAPES).to_string().into();
|
||||
} else {
|
||||
normalize_uris(value);
|
||||
normalize_csharp_uris(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,16 +42,6 @@ pub(crate) fn normalize_uris(value: &mut serde_json::Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `uri` with the characters a URI can't hold escaped; `None` when nothing changes or it is
|
||||
/// not a document URI this applies to (other schemes are passed back to servers as given).
|
||||
fn escaped_uri(uri: &str) -> Option<String> {
|
||||
if !(uri.starts_with("file:") || uri.starts_with("csharp:")) {
|
||||
return None;
|
||||
}
|
||||
let escaped = utf8_percent_encode(uri, URI_ESCAPES).to_string();
|
||||
(escaped != uri).then_some(escaped)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CSharpMetadata {
|
||||
@@ -225,7 +202,7 @@ mod tests {
|
||||
{ "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } }
|
||||
]);
|
||||
let original = response.clone();
|
||||
normalize_uris(&mut response);
|
||||
normalize_csharp_uris(&mut response);
|
||||
assert_eq!(response[0]["uri"], encoded);
|
||||
assert_eq!(response[0]["source"], uri);
|
||||
assert_eq!(response[1]["location"]["targetUri"], encoded);
|
||||
@@ -233,31 +210,6 @@ mod tests {
|
||||
assert!(encoded.parse::<lsp_types::Uri>().is_ok());
|
||||
}
|
||||
|
||||
/// #3089: jdtls's Introduce Variable edit for a project under a Chinese path names the file
|
||||
/// with the characters unescaped, and the resolved code action failed to deserialize.
|
||||
#[test]
|
||||
fn file_uris_with_unicode_and_spaces_deserialize_in_workspace_edits() {
|
||||
let uri = "file:///C:/项目/my app/src/A.java";
|
||||
let encoded = "file:///C:/%E9%A1%B9%E7%9B%AE/my%20app/src/A.java";
|
||||
let edit = serde_json::json!([{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } }, "newText": "x" }]);
|
||||
let mut action = serde_json::json!({
|
||||
"title": "Extract to local variable",
|
||||
"edit": {
|
||||
"documentChanges": [{ "textDocument": { "uri": uri, "version": 3 }, "edits": edit }],
|
||||
"changes": { (uri): edit },
|
||||
},
|
||||
"data": { "uri": uri },
|
||||
});
|
||||
assert!(serde_json::from_value::<lsp_types::CodeAction>(action.clone()).is_err());
|
||||
normalize_uris(&mut action);
|
||||
let action: lsp_types::CodeAction = serde_json::from_value(action.clone()).unwrap();
|
||||
let edit = action.edit.unwrap();
|
||||
let Some(lsp_types::DocumentChanges::Edits(edits)) = edit.document_changes else { panic!() };
|
||||
assert_eq!(edits[0].text_document.uri.as_str(), encoded);
|
||||
assert_eq!(edit.changes.unwrap().keys().next().unwrap().as_str(), encoded);
|
||||
assert_eq!(action.data.unwrap()["uri"], uri, "opaque data is passed back unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdt_titles() {
|
||||
assert_eq!(title(JDT, true), "SpringApplication.class");
|
||||
|
||||
@@ -2178,76 +2178,6 @@ async fn missing_server_reports_once() {
|
||||
assert!(h.mgr.semantic_tokens(doc).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn checking_a_document_saves_it_and_waits_for_fresh_diagnostics() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let s = h.launcher.state("mock");
|
||||
let (doc, path) = h.open("a.mk", "bad\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
// The server answers the save with a publish, as an on-save check (`cargo check`) does.
|
||||
let (server, uri) = (s.clone(), uri_of(&path));
|
||||
let publisher = tokio::spawn(async move {
|
||||
eventually(|| !server.notified("textDocument/didSave").is_empty()).await;
|
||||
let diag = json!({ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":3}},
|
||||
"severity": 1, "message": "on save" });
|
||||
server.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri, "diagnostics": [diag] })).unwrap();
|
||||
});
|
||||
let cancel = CancellationToken::new();
|
||||
let changed = Some(std::time::Instant::now());
|
||||
let check = h.mgr.check_document(doc, changed, Duration::from_secs(20), &cancel).await.unwrap();
|
||||
publisher.await.unwrap();
|
||||
assert_eq!(check.diagnostics.iter().map(|d| d.message.as_str()).collect::<Vec<_>>(), ["on save"]);
|
||||
assert!(check.servers[0].checked && !check.servers[0].incomplete, "{:?}", check.servers);
|
||||
|
||||
// A change the server never answered: what it has is reported as not fresh.
|
||||
let changed = Some(std::time::Instant::now());
|
||||
let stale = h.mgr.check_document(doc, changed, Duration::from_millis(800), &cancel).await.unwrap();
|
||||
assert!(stale.servers[0].checked && stale.servers[0].incomplete, "{:?}", stale.servers);
|
||||
assert_eq!(stale.diagnostics.len(), 1);
|
||||
|
||||
// The text changed before the call (the file watcher reloaded it), so the caller saw no
|
||||
// change of its own: the old publication is still not fresh.
|
||||
h.mgr.did_change(doc, 2, &[change(rng(0, 0, 0, 3), "good")], &|| "good\n".to_owned());
|
||||
let reloaded = h.mgr.check_document(doc, None, Duration::from_millis(800), &cancel).await.unwrap();
|
||||
assert!(reloaded.servers[0].incomplete, "{:?}", reloaded.servers);
|
||||
|
||||
// A late publication tagged with the old version is no answer for the current text.
|
||||
s.peer()
|
||||
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "version": 1, "diagnostics": [] }))
|
||||
.unwrap();
|
||||
let late = h.mgr.check_document(doc, None, Duration::from_millis(800), &cancel).await.unwrap();
|
||||
assert!(late.servers[0].incomplete, "{:?}", late.servers);
|
||||
// One for the current version is.
|
||||
s.peer()
|
||||
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "version": 2, "diagnostics": [] }))
|
||||
.unwrap();
|
||||
let fresh = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
|
||||
assert!(fresh.servers[0].checked && !fresh.servers[0].incomplete, "{:?}", fresh.servers);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn checking_a_document_reports_servers_that_cannot_check() {
|
||||
let cancel = CancellationToken::new();
|
||||
let h = harness(&[("ghost", json!({}))]);
|
||||
h.launcher.state("ghost").missing.store(true, Ordering::SeqCst);
|
||||
let (doc, _) = h.open("a.mk", "x");
|
||||
h.wait_status("ghost", ServerStatus::Missing).await;
|
||||
let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
|
||||
assert!(check.diagnostics.is_empty());
|
||||
let s = &check.servers[0];
|
||||
assert_eq!((s.name.as_str(), s.status, s.checked), ("ghost", ServerStatus::Missing, false));
|
||||
assert_eq!(s.message.as_deref(), Some("install ghost"));
|
||||
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.launch_error = Some("needs JDK 21".into())));
|
||||
let (doc, _) = h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Misconfigured).await;
|
||||
let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
|
||||
assert_eq!(check.servers[0].status, ServerStatus::Misconfigured);
|
||||
assert!(check.servers[0].message.as_deref().is_some_and(|m| m.contains("needs JDK 21")), "{:?}", check.servers);
|
||||
assert!(!check.servers[0].checked);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn launch_error_fails_without_starting_the_server() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! `<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. `<app-data>/mcp/token` keeps the token across runs when
|
||||
//! the user chose a fixed token.
|
||||
//! the server stops.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
@@ -15,28 +14,6 @@ pub fn discovery_path(app_data: &Path) -> PathBuf {
|
||||
app_data.join(DISCOVERY_FILE)
|
||||
}
|
||||
|
||||
/// The kept token, relative to the app data directory.
|
||||
pub const TOKEN_FILE: &str = "mcp/token";
|
||||
|
||||
/// The kept token of the IDE whose app data directory is `app_data`.
|
||||
pub fn token_path(app_data: &Path) -> PathBuf {
|
||||
app_data.join(TOKEN_FILE)
|
||||
}
|
||||
|
||||
/// The token kept at `path`; a new one is made and kept there when there is none (or the
|
||||
/// file doesn't hold one). Readable only by the user.
|
||||
pub fn kept_token(path: &Path) -> io::Result<String> {
|
||||
if let Ok(text) = std::fs::read_to_string(path) {
|
||||
let token = text.trim();
|
||||
if token.len() == 64 && token.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Ok(token.to_owned());
|
||||
}
|
||||
}
|
||||
let token = crate::server::new_token()?;
|
||||
write_atomic(path, token.as_bytes())?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Discovery {
|
||||
pub port: u16,
|
||||
@@ -55,8 +32,15 @@ impl Discovery {
|
||||
|
||||
/// 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)?;
|
||||
write_atomic(path, &bytes)
|
||||
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.
|
||||
@@ -67,18 +51,6 @@ impl Discovery {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `bytes` to `path` atomically, readable only by the user.
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> 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 written = write_private(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
|
||||
if written.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
use std::io::Write;
|
||||
@@ -119,23 +91,4 @@ mod tests {
|
||||
Discovery::remove_if_ours(&path);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_one_token_until_its_file_is_removed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = token_path(tmp.path());
|
||||
let first = kept_token(&path).unwrap();
|
||||
assert_eq!(first.len(), 64);
|
||||
assert_eq!(kept_token(&path).unwrap(), first, "the same token on the next run");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
std::fs::write(&path, "not a token").unwrap();
|
||||
let replaced = kept_token(&path).unwrap();
|
||||
assert_ne!(replaced, first);
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert_ne!(kept_token(&path).unwrap(), replaced, "a removed token is made anew");
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,8 @@ mod protocol;
|
||||
mod server;
|
||||
pub mod stdio;
|
||||
|
||||
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
|
||||
pub use server::{McpServer, ServerOptions};
|
||||
pub use discovery::{DISCOVERY_FILE, Discovery, discovery_path};
|
||||
pub use server::McpServer;
|
||||
pub use tokio_util::sync::CancellationToken;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
+25
-292
@@ -3,39 +3,17 @@
|
||||
use crate::discovery::Discovery;
|
||||
use crate::{CancellationToken, PROJECT_HEADER, ToolRegistry, protocol};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Either, Full, Limited};
|
||||
use hyper::body::{Frame, Incoming};
|
||||
use http_body_util::{BodyExt, Full, Limited};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::header::{self, HeaderMap, HeaderValue};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
use tokio::task::{JoinError, JoinHandle};
|
||||
use tokio_util::sync::DropGuard;
|
||||
|
||||
/// Largest request body.
|
||||
const MAX_BODY: usize = 8 << 20;
|
||||
/// A call still running after this long is answered on an event stream (when the client
|
||||
/// takes one), so that it can take as long as it needs: clients give up on a POST whose
|
||||
/// response has not started (Claude Code after a minute).
|
||||
#[cfg(not(test))]
|
||||
const STREAM_AFTER: Duration = Duration::from_secs(1);
|
||||
#[cfg(test)]
|
||||
const STREAM_AFTER: Duration = Duration::from_millis(100);
|
||||
/// How often a streamed call reports that it is still running: progress notifications reset
|
||||
/// the client's idle timeout (Claude Code's is five minutes).
|
||||
#[cfg(not(test))]
|
||||
const HEARTBEAT: Duration = Duration::from_secs(15);
|
||||
#[cfg(test)]
|
||||
const HEARTBEAT: Duration = Duration::from_millis(100);
|
||||
|
||||
type Handled = Result<Option<Value>, Value>;
|
||||
type Body = Either<Full<Bytes>, EventStream>;
|
||||
|
||||
struct Shared {
|
||||
registry: Arc<dyn ToolRegistry>,
|
||||
@@ -49,25 +27,12 @@ struct Shared {
|
||||
/// removes its discovery file.
|
||||
pub struct McpServer {
|
||||
port: u16,
|
||||
/// Why the port asked for in [`ServerOptions::port`] was not used.
|
||||
port_unavailable: Option<String>,
|
||||
token: String,
|
||||
discovery: Option<PathBuf>,
|
||||
shutdown: watch::Sender<bool>,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
/// How [`McpServer::start_with`] listens.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ServerOptions {
|
||||
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
|
||||
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
|
||||
/// `None`: a free port.
|
||||
pub port: Option<u16>,
|
||||
/// The bearer token; `None`: a new one.
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -76,32 +41,10 @@ impl McpServer {
|
||||
discovery: Option<PathBuf>,
|
||||
runtime: &tokio::runtime::Handle,
|
||||
) -> std::io::Result<Self> {
|
||||
Self::start_with(registry, discovery, runtime, ServerOptions::default())
|
||||
}
|
||||
|
||||
/// [`Self::start`] on the port and with the token of `options`.
|
||||
pub fn start_with(
|
||||
registry: Arc<dyn ToolRegistry>,
|
||||
discovery: Option<PathBuf>,
|
||||
runtime: &tokio::runtime::Handle,
|
||||
options: ServerOptions,
|
||||
) -> std::io::Result<Self> {
|
||||
let (listener, port_unavailable) = match options.port {
|
||||
Some(port) => match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(listener) => (listener, None),
|
||||
Err(e) => {
|
||||
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
|
||||
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
|
||||
}
|
||||
},
|
||||
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
|
||||
};
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
|
||||
listener.set_nonblocking(true)?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let token = match options.token {
|
||||
Some(token) => token,
|
||||
None => new_token()?,
|
||||
};
|
||||
let token = new_token()?;
|
||||
let listener = {
|
||||
let _entered = runtime.enter();
|
||||
tokio::net::TcpListener::from_std(listener)?
|
||||
@@ -126,18 +69,13 @@ impl McpServer {
|
||||
});
|
||||
runtime.spawn(accept(listener, shared, stopped));
|
||||
tracing::info!("MCP server on 127.0.0.1:{port}");
|
||||
Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
|
||||
Ok(Self { port, token, discovery, shutdown, cancel })
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Why the port of [`ServerOptions::port`] is not the one listened on.
|
||||
pub fn port_unavailable(&self) -> Option<&str> {
|
||||
self.port_unavailable.as_deref()
|
||||
}
|
||||
|
||||
/// The bearer token of this run.
|
||||
pub fn token(&self) -> &str {
|
||||
&self.token
|
||||
@@ -202,11 +140,15 @@ async fn accept(listener: tokio::net::TcpListener, shared: Arc<Shared>, mut stop
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(req: Request<Incoming>, shared: Arc<Shared>, scope: String) -> Result<Response<Body>, Infallible> {
|
||||
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<Body>;
|
||||
type Reply = Response<Full<Bytes>>;
|
||||
|
||||
async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) -> Reply {
|
||||
if req.uri().path() != "/mcp" {
|
||||
@@ -221,7 +163,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
return response;
|
||||
}
|
||||
if req.method() != Method::POST {
|
||||
// No stream of server-initiated messages: every answer is in the POST's response.
|
||||
// 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;
|
||||
@@ -233,41 +175,25 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
}
|
||||
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 streams =
|
||||
req.headers().get_all(header::ACCEPT).iter().any(|v| v.to_str().is_ok_and(|v| v.contains("text/event-stream")));
|
||||
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 message = serde_json::from_slice::<Value>(&body).ok();
|
||||
let initializing = message.as_ref().is_some_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
|
||||
let id = message.as_ref().and_then(|m| m.get("id")).cloned().unwrap_or(Value::Null);
|
||||
let progress_token = message.as_ref().and_then(|m| m.pointer("/params/_meta/progressToken")).cloned();
|
||||
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();
|
||||
// Cancels the call when the client goes away (the response is dropped).
|
||||
let guard = cancel.clone().drop_guard();
|
||||
let mut handled = tokio::task::spawn_blocking(move || {
|
||||
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)
|
||||
});
|
||||
if streams && !initializing {
|
||||
match tokio::time::timeout(STREAM_AFTER, &mut handled).await {
|
||||
Ok(done) => return answer(done, false, &response_scope),
|
||||
Err(_) => return event_stream(handled, id, progress_token, guard),
|
||||
}
|
||||
}
|
||||
let done = handled.await;
|
||||
drop(guard);
|
||||
answer(done, initializing, &response_scope)
|
||||
}
|
||||
|
||||
fn answer(handled: Result<Handled, JoinError>, initializing: bool, scope: &str) -> Reply {
|
||||
})
|
||||
.await;
|
||||
match handled {
|
||||
Ok(Ok(Some(reply))) => {
|
||||
let mut response = json(StatusCode::OK, &reply);
|
||||
if initializing && let Ok(value) = HeaderValue::from_str(scope) {
|
||||
if initializing && let Ok(value) = HeaderValue::from_str(&response_scope) {
|
||||
response.headers_mut().insert("mcp-session-id", value);
|
||||
}
|
||||
response
|
||||
@@ -278,71 +204,6 @@ fn answer(handled: Result<Handled, JoinError>, initializing: bool, scope: &str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers a call that is still running on an event stream: a progress notification every
|
||||
/// [`HEARTBEAT`] while it runs (a comment when the client gave no progress token), then its
|
||||
/// answer. The call is cancelled as soon as the client goes away.
|
||||
fn event_stream(mut handled: JoinHandle<Handled>, id: Value, token: Option<Value>, guard: DropGuard) -> Reply {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(4);
|
||||
tokio::spawn(async move {
|
||||
let _guard = guard;
|
||||
let started = tokio::time::Instant::now();
|
||||
let mut beat = tokio::time::interval_at(started + HEARTBEAT, HEARTBEAT);
|
||||
loop {
|
||||
tokio::select! {
|
||||
done = &mut handled => {
|
||||
let reply = match done {
|
||||
Ok(Ok(Some(reply)) | Err(reply)) => reply,
|
||||
Ok(Ok(None)) => return,
|
||||
Err(e) => protocol::error_response(id, -32603, &format!("the request failed: {e}")),
|
||||
};
|
||||
let _ = tx.send(event(&reply)).await;
|
||||
return;
|
||||
}
|
||||
_ = beat.tick() => {
|
||||
let elapsed = started.elapsed();
|
||||
let still_running = match &token {
|
||||
Some(token) => event(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/progress",
|
||||
"params": {
|
||||
"progressToken": token,
|
||||
"progress": elapsed.as_secs_f64(),
|
||||
"message": format!("running for {}s", elapsed.as_secs()),
|
||||
},
|
||||
})),
|
||||
None => Bytes::from_static(b": running\n\n"),
|
||||
};
|
||||
if tx.send(still_running).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ = tx.closed() => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut response = Response::new(Either::Right(EventStream(rx)));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
response
|
||||
}
|
||||
|
||||
/// One server-sent event carrying a JSON-RPC message.
|
||||
fn event(message: &Value) -> Bytes {
|
||||
Bytes::from(format!("event: message\ndata: {message}\n\n"))
|
||||
}
|
||||
|
||||
/// A `text/event-stream` body, written by [`event_stream`]'s task.
|
||||
pub(crate) struct EventStream(tokio::sync::mpsc::Receiver<Bytes>);
|
||||
|
||||
impl hyper::body::Body for EventStream {
|
||||
type Data = Bytes;
|
||||
type Error = Infallible;
|
||||
|
||||
fn poll_frame(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
|
||||
self.get_mut().0.poll_recv(cx).map(|data| data.map(|data| Ok(Frame::data(data))))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -381,22 +242,22 @@ fn project_of(req: &Request<Incoming>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn plain(status: StatusCode, text: &str) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::from(text.to_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: &Value) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::from(value.to_string()))));
|
||||
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) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::new())));
|
||||
fn empty(status: StatusCode) -> Response<Full<Bytes>> {
|
||||
let mut response = Response::new(Full::new(Bytes::new()));
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
}
|
||||
@@ -404,134 +265,6 @@ fn empty(status: StatusCode) -> Reply {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// `slow` sleeps `ms` milliseconds; `wait` runs until it is cancelled, which it records.
|
||||
struct Slow(Arc<AtomicBool>);
|
||||
|
||||
impl ToolRegistry for Slow {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![PathBuf::from("/project")]
|
||||
}
|
||||
fn tools(&self, _: &Path) -> Result<Vec<crate::Tool>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn call(
|
||||
&self,
|
||||
_: &Path,
|
||||
name: &str,
|
||||
arguments: &str,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<String, crate::ToolCallError> {
|
||||
if name == "wait" {
|
||||
while !cancel.is_cancelled() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
return Err(crate::ToolCallError::Failed("cancelled".into()));
|
||||
}
|
||||
let ms = serde_json::from_str::<Value>(arguments).unwrap()["ms"].as_u64().unwrap();
|
||||
std::thread::sleep(Duration::from_millis(ms));
|
||||
Ok("done".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn start() -> (McpServer, Arc<AtomicBool>) {
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let registry = Arc::new(Slow(cancelled.clone()));
|
||||
(McpServer::start(registry, None, &tokio::runtime::Handle::current()).unwrap(), cancelled)
|
||||
}
|
||||
|
||||
fn call(name: &str, ms: u64, token: Option<&str>) -> Value {
|
||||
let mut params = json!({ "name": name, "arguments": { "ms": ms } });
|
||||
if let Some(token) = token {
|
||||
params["_meta"] = json!({ "progressToken": token });
|
||||
}
|
||||
json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": params })
|
||||
}
|
||||
|
||||
async fn send(server: &McpServer, accept: &str, body: &Value) -> tokio::net::TcpStream {
|
||||
let body = body.to_string();
|
||||
let port = server.port();
|
||||
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||
let request = format!(
|
||||
"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {}\r\nContent-Type: application/json\r\nAccept: {accept}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
server.token(),
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(request.as_bytes()).await.unwrap();
|
||||
stream
|
||||
}
|
||||
|
||||
/// The whole response, lowercased headers and the body as sent (chunked or not).
|
||||
async fn exchange(server: &McpServer, accept: &str, body: &Value) -> (String, String) {
|
||||
let mut out = String::new();
|
||||
send(server, accept, body).await.read_to_string(&mut out).await.unwrap();
|
||||
let (head, body) = out.split_once("\r\n\r\n").unwrap();
|
||||
(head.to_ascii_lowercase(), body.to_owned())
|
||||
}
|
||||
|
||||
fn events(body: &str) -> Vec<Value> {
|
||||
body.lines().filter_map(|l| l.strip_prefix("data: ")).map(|d| serde_json::from_str(d).unwrap()).collect()
|
||||
}
|
||||
|
||||
const BOTH: &str = "application/json, text/event-stream";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn long_calls_are_answered_on_an_event_stream_that_reports_progress() {
|
||||
let (server, _) = start();
|
||||
|
||||
let (head, body) = exchange(&server, BOTH, &call("slow", 0, Some("p"))).await;
|
||||
assert!(head.contains("content-type: application/json"), "{head}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["content"][0]["text"], "done");
|
||||
|
||||
let (head, body) = exchange(&server, BOTH, &call("slow", 450, Some("p"))).await;
|
||||
assert!(head.contains("content-type: text/event-stream"), "{head}");
|
||||
let messages = events(&body);
|
||||
let (answer, progress) = messages.split_last().unwrap();
|
||||
assert_eq!(answer["id"], 3);
|
||||
assert_eq!(answer["result"]["content"][0]["text"], "done");
|
||||
assert!(progress.len() >= 2, "{body}");
|
||||
for pair in progress.windows(2) {
|
||||
assert!(pair[0]["params"]["progress"].as_f64() < pair[1]["params"]["progress"].as_f64(), "{body}");
|
||||
}
|
||||
for p in progress {
|
||||
assert_eq!((&p["method"], &p["params"]["progressToken"]), (&json!("notifications/progress"), &json!("p")));
|
||||
}
|
||||
|
||||
// Without a progress token, comments keep the stream going.
|
||||
let (_, body) = exchange(&server, BOTH, &call("slow", 300, None)).await;
|
||||
assert!(body.contains(": running"), "{body}");
|
||||
assert_eq!(events(&body).len(), 1, "{body}");
|
||||
|
||||
// A client that takes only JSON waits for it.
|
||||
let (head, body) = exchange(&server, "application/json", &call("slow", 300, Some("p"))).await;
|
||||
assert!(head.contains("content-type: application/json"), "{head}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["id"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_streamed_call_is_cancelled_when_the_client_goes_away() {
|
||||
let (server, cancelled) = start();
|
||||
let mut stream = send(&server, BOTH, &call("wait", 0, Some("p"))).await;
|
||||
let mut head = Vec::new();
|
||||
while !String::from_utf8_lossy(&head).contains("\r\n\r\n") {
|
||||
let mut buf = [0u8; 1024];
|
||||
let n = stream.read(&mut buf).await.unwrap();
|
||||
assert!(n > 0, "the stream ended early");
|
||||
head.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
assert!(String::from_utf8_lossy(&head).to_ascii_lowercase().contains("text/event-stream"));
|
||||
drop(stream);
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !cancelled.load(Ordering::SeqCst) {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the call was cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_loopback_hosts_and_origins_of_this_server_pass() {
|
||||
|
||||
+14
-106
@@ -35,8 +35,7 @@ pub fn run(discovery: &Path, project: Option<PathBuf>) -> i32 {
|
||||
}
|
||||
|
||||
/// Relays each line of `input` (a JSON-RPC message or batch) to the IDE and
|
||||
/// writes what it answers, if anything, one message per line to `output`: the
|
||||
/// answer, and before it the progress notifications of a long call.
|
||||
/// 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,
|
||||
@@ -45,37 +44,23 @@ where
|
||||
let scope = crate::server::new_token()?;
|
||||
let mut lines = input.lines();
|
||||
let mut pending = tokio::task::JoinSet::new();
|
||||
let (sent, mut received) = tokio::sync::mpsc::unbounded_channel::<Value>();
|
||||
let mut eof = false;
|
||||
loop {
|
||||
if eof && pending.is_empty() && received.is_empty() {
|
||||
break;
|
||||
}
|
||||
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, sent) =
|
||||
(discovery.to_owned(), project.to_owned(), scope.clone(), sent.clone());
|
||||
pending.spawn(async move {
|
||||
if let Some(reply) = relay(&discovery, &project, &message, &scope, &sent).await {
|
||||
let _ = sent.send(reply);
|
||||
}
|
||||
});
|
||||
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}"))),
|
||||
}
|
||||
}
|
||||
Some(message) = received.recv() => Some(message),
|
||||
done = pending.join_next(), if !pending.is_empty() => {
|
||||
if let Some(Err(e)) = done {
|
||||
return Err(std::io::Error::other(e));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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?;
|
||||
@@ -87,24 +72,15 @@ where
|
||||
}
|
||||
|
||||
/// The IDE's answer to `message`, or an error response for each request in
|
||||
/// it when the IDE can't be reached. An answer on an event stream goes to `sent`
|
||||
/// with the messages before it.
|
||||
async fn relay(
|
||||
discovery: &Path,
|
||||
project: &Path,
|
||||
message: &Value,
|
||||
scope: &str,
|
||||
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
|
||||
) -> Option<Value> {
|
||||
let failure = match post(discovery, project, message, scope, sent).await {
|
||||
Ok(Answer::Streamed { answered: true }) => return None,
|
||||
Ok(Answer::Streamed { answered: false }) => "OpenIDEA's answer ended early".to_owned(),
|
||||
Ok(Answer::Body(StatusCode::ACCEPTED, _)) => return None,
|
||||
Ok(Answer::Body(_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) {
|
||||
/// 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(Answer::Body(status, _)) => format!("OpenIDEA answered {status}"),
|
||||
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();
|
||||
@@ -124,23 +100,9 @@ fn requests(message: &Value) -> Vec<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
enum Answer {
|
||||
Body(StatusCode, Bytes),
|
||||
/// The messages of an event stream went to the channel; `answered`: one was a response.
|
||||
Streamed {
|
||||
answered: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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,
|
||||
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
|
||||
) -> Result<Answer, String> {
|
||||
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 › Agents › MCP Server",
|
||||
@@ -163,60 +125,6 @@ async fn post(
|
||||
.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 streamed = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.is_some_and(|v| v.to_str().is_ok_and(|v| v.starts_with("text/event-stream")));
|
||||
let mut body = response.into_body();
|
||||
if !streamed {
|
||||
let body = body.collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
|
||||
return Ok(Answer::Body(status, body));
|
||||
}
|
||||
let (mut buffer, mut answered) = (Vec::new(), false);
|
||||
while let Some(frame) = body.frame().await {
|
||||
let frame = frame.map_err(|e| format!("OpenIDEA's answer: {e}"))?;
|
||||
let Ok(data) = frame.into_data() else { continue };
|
||||
buffer.extend_from_slice(&data);
|
||||
for data in take_events(&mut buffer) {
|
||||
if let Ok(message) = serde_json::from_str::<Value>(&data) {
|
||||
answered |= message.get("method").is_none();
|
||||
let _ = sent.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Answer::Streamed { answered })
|
||||
}
|
||||
|
||||
/// The `data` of each complete server-sent event at the start of `buffer`, which keeps
|
||||
/// the rest. Comments and other fields are skipped.
|
||||
fn take_events(buffer: &mut Vec<u8>) -> Vec<String> {
|
||||
let mut events = Vec::new();
|
||||
while let Some(end) = buffer.windows(2).position(|w| w == b"\n\n") {
|
||||
let block: Vec<u8> = buffer.drain(..end + 2).collect();
|
||||
let block = String::from_utf8_lossy(&block).replace("\r\n", "\n");
|
||||
let data: Vec<&str> = block
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("data:"))
|
||||
.map(|d| d.strip_prefix(' ').unwrap_or(d))
|
||||
.collect();
|
||||
if !data.is_empty() {
|
||||
events.push(data.join("\n"));
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::take_events;
|
||||
|
||||
#[test]
|
||||
fn events_are_taken_whole_and_comments_skipped() {
|
||||
let mut buffer = b": running\n\nevent: message\ndata: {\"a\":1}\n\nevent: message\ndata: {\"b\"".to_vec();
|
||||
assert_eq!(take_events(&mut buffer), ["{\"a\":1}"]);
|
||||
assert_eq!(buffer, b"event: message\ndata: {\"b\"");
|
||||
buffer.extend_from_slice(b":2}\n\n");
|
||||
assert_eq!(take_events(&mut buffer), ["{\"b\":2}"]);
|
||||
assert!(buffer.is_empty());
|
||||
}
|
||||
let body = response.into_body().collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
|
||||
Ok((status, body))
|
||||
}
|
||||
Loaded 100 of 621 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user