Compare commits

..
Author SHA1 Message Date
l0ng-ai ac7139c94d feat(plugins): open two texts in the diff viewer (API 1.7)
Plugins could show their own content in an editor tab (open-content-tab) but
could open the diff viewer only as the reply to one of that tab's actions. A
review plugin (a merge request's files, say) had no way to show a file's
before and after.

views.open-diff opens two texts in the invoking window's diff viewer, with
optional labels and title and a file name that picks the syntax highlighting;
web views send { op: "open-diff", ... }. The SDK wraps it as host::open_diff
with a mock. The plugin API moves to 1.7.0 (1.6.0 is released) and the checked-in
components are rebuilt.
2026-11-13 11:47:06 +08:00
352 changed files with 1076 additions and 11637 deletions

No files matched your search

-9
View File
@@ -151,13 +151,6 @@ jobs:
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower() $hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256" "$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber 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 # 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. # 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"]], [/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/), ...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-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 = {}; const platforms = {};
for (const sig of sigs) { for (const sig of sigs) {
Generated
+44 -44
View File
@@ -4144,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "ide-agent" name = "ide-agent"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"dunce", "dunce",
"encoding_rs", "encoding_rs",
@@ -4168,7 +4168,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-analysis" name = "ide-analysis"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"ast-grep-core", "ast-grep-core",
"ide-core", "ide-core",
@@ -4182,7 +4182,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-android" name = "ide-android"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"serde", "serde",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-build" name = "ide-build"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"blake3", "blake3",
"chardetng", "chardetng",
@@ -4211,7 +4211,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-cli" name = "ide-cli"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"dirs 6.0.0", "dirs 6.0.0",
"serde", "serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-clipboard" name = "ide-clipboard"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"gtk", "gtk",
"objc2 0.6.5", "objc2 0.6.5",
@@ -4235,7 +4235,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-core" name = "ide-core"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"dunce", "dunce",
"encoding_rs", "encoding_rs",
@@ -4252,7 +4252,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-coverage" name = "ide-coverage"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"ide-run", "ide-run",
@@ -4266,7 +4266,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-dap" name = "ide-dap"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"futures", "futures",
"ide-core", "ide-core",
@@ -4292,7 +4292,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-db" name = "ide-db"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bb8", "bb8",
@@ -4328,7 +4328,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-docker" name = "ide-docker"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"serde", "serde",
@@ -4341,7 +4341,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-extension" name = "ide-extension"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"blake3", "blake3",
@@ -4362,7 +4362,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-fonts" name = "ide-fonts"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-text", "objc2-core-text",
@@ -4374,7 +4374,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-github" name = "ide-github"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"ide-vcs", "ide-vcs",
@@ -4387,7 +4387,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-heap" name = "ide-heap"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"criterion", "criterion",
"ide-core", "ide-core",
@@ -4405,7 +4405,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-host" name = "ide-host"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"blake3", "blake3",
@@ -4476,7 +4476,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-http" name = "ide-http"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"ide-vfs", "ide-vfs",
@@ -4495,7 +4495,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-index" name = "ide-index"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"blake3", "blake3",
"criterion", "criterion",
@@ -4517,7 +4517,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-jdwp" name = "ide-jdwp"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"ide-jvm-inspect", "ide-jvm-inspect",
"proptest", "proptest",
@@ -4529,7 +4529,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-jsonrpc" name = "ide-jsonrpc"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"encoding_rs", "encoding_rs",
"futures", "futures",
@@ -4546,7 +4546,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-jvm-attach" name = "ide-jvm-attach"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"libc", "libc",
"tempfile", "tempfile",
@@ -4555,7 +4555,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-jvm-inspect" name = "ide-jvm-inspect"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"futures", "futures",
"serde", "serde",
@@ -4566,7 +4566,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-language" name = "ide-language"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"blake3", "blake3",
"criterion", "criterion",
@@ -4623,7 +4623,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-lsp" name = "ide-lsp"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"dunce", "dunce",
"encoding_rs", "encoding_rs",
@@ -4651,7 +4651,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-mcp" name = "ide-mcp"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"bytes", "bytes",
"dunce", "dunce",
@@ -4670,7 +4670,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-notebook" name = "ide-notebook"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"futures", "futures",
"ide-core", "ide-core",
@@ -4693,7 +4693,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-plugin" name = "ide-plugin"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"blake3", "blake3",
"dunce", "dunce",
@@ -4716,7 +4716,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-profiler" name = "ide-profiler"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"ide-jvm-attach", "ide-jvm-attach",
@@ -4730,7 +4730,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-remote" name = "ide-remote"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"dunce", "dunce",
"futures", "futures",
@@ -4750,7 +4750,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-run" name = "ide-run"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"dunce", "dunce",
"encoding_rs", "encoding_rs",
@@ -4774,7 +4774,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-scaffold" name = "ide-scaffold"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"ide-tools", "ide-tools",
@@ -4790,7 +4790,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-sdk" name = "ide-sdk"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"dunce", "dunce",
"ide-core", "ide-core",
@@ -4811,7 +4811,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-search" name = "ide-search"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"criterion", "criterion",
"encoding_rs", "encoding_rs",
@@ -4832,7 +4832,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-spell" name = "ide-spell"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"parking_lot", "parking_lot",
@@ -4845,7 +4845,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-ssh" name = "ide-ssh"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"dirs 6.0.0", "dirs 6.0.0",
"flate2", "flate2",
@@ -4870,7 +4870,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-syntax" name = "ide-syntax"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"ide-language", "ide-language",
@@ -4885,7 +4885,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-terminal" name = "ide-terminal"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"libc", "libc",
@@ -4898,7 +4898,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-text" name = "ide-text"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"criterion", "criterion",
"ide-core", "ide-core",
@@ -4911,7 +4911,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-tools" name = "ide-tools"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"flate2", "flate2",
"hex", "hex",
@@ -4932,7 +4932,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-vcs" name = "ide-vcs"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"blake3", "blake3",
"dunce", "dunce",
@@ -4953,7 +4953,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-vfs" name = "ide-vfs"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"chardetng", "chardetng",
"dunce", "dunce",
@@ -4977,7 +4977,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-web" name = "ide-web"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"dirs 6.0.0", "dirs 6.0.0",
"ide-core", "ide-core",
@@ -4993,7 +4993,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-workspace" name = "ide-workspace"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"blake3", "blake3",
"dunce", "dunce",
@@ -6594,7 +6594,7 @@ dependencies = [
[[package]] [[package]]
name = "openidea" name = "openidea"
version = "0.1.44" version = "0.1.43"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"blake3", "blake3",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"] exclude = ["sdk", "plugins"]
[workspace.package] [workspace.package]
version = "0.1.44" version = "0.1.43"
edition = "2024" edition = "2024"
rust-version = "1.96" rust-version = "1.96"
license = "Apache-2.0" license = "Apache-2.0"
+24 -79
View File
@@ -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> { fn crlf_to_lf(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len()); let mut out = Vec::with_capacity(bytes.len());
for (i, &b) in bytes.iter().enumerate() { for (i, &b) in bytes.iter().enumerate() {
@@ -420,31 +411,25 @@ pub(crate) fn accept(
let copyable = |rel: &str| task_side.allows(rel) && checkout_side.allows(rel); let copyable = |rel: &str| task_side.allows(rel) && checkout_side.allows(rel);
let renamed_from: BTreeMap<String, String> = let renamed_from: BTreeMap<String, String> =
repo.diff_with_worktree(base)?.into_iter().filter_map(|f| Some((f.path, f.old_path?))).collect(); 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 // Brings one path of the checkout to the task's version, unless the checkout changed it itself.
// the checkout changed it itself. let apply = |rel: &str| -> IdeResult<bool> {
let target = |rel: &str| -> IdeResult<Option<Option<Vec<u8>>>> { let local = local_root.join(rel);
let in_task = repo.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 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`). // The checkout may hold the committed LF text with CRLF line ends (`core.autocrlf`).
let unchanged = match (&local_bytes, &base_bytes) { let unchanged = match (&local_bytes, &base_bytes) {
(Some(now), Some(at_base)) => now == at_base || crlf_to_lf(now) == *at_base, (Some(now), Some(at_base)) => now == at_base || crlf_to_lf(now) == *at_base,
(now, at_base) => now == at_base, (now, at_base) => now == at_base,
}; };
Ok(unchanged.then_some(current)) if !unchanged {
}; return Ok(false);
// 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(());
} }
match bytes { match current {
Some(bytes) => { Some(bytes) => {
if let Some(parent) = local.parent() { if let Some(parent) = local.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
@@ -456,12 +441,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) Ok(true)
}; };
let mut accepted = Vec::with_capacity(changes.len()); let mut accepted = Vec::with_capacity(changes.len());
@@ -487,9 +466,8 @@ pub(crate) fn accept(
// A case-only rename (`a.txt` → `A.txt`). Where the disk resolves both // 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 // 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: // 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 // the old one's unchanged-ness (it is the file the checkout had) decides —
// checkout had, the new one absent or already the task's — then the task's // then the task's version lands at the new name and the old one goes.
// version lands at the new name before the old one goes.
let case_only = let case_only =
old.is_some_and(|o| o != &change.rel_path && o.to_lowercase() == change.rel_path.to_lowercase()); old.is_some_and(|o| o != &change.rel_path && o.to_lowercase() == change.rel_path.to_lowercase());
if case_only { if case_only {
@@ -498,18 +476,20 @@ pub(crate) fn accept(
if same_file(&old_path, &new_path) { if same_file(&old_path, &new_path) {
std::fs::rename(&old_path, &new_path)?; std::fs::rename(&old_path, &new_path)?;
} else { } else {
let Some(old_target) = target(old)? else { if !apply(old)? {
skipped.push(skip(old)); skipped.push(skip(old));
continue; 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)?; // `apply(old)` removed the old file (the task's tree has it only at
write(old, old_target)?; // 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()); accepted.push(change.rel_path.clone());
continue; continue;
} }
@@ -980,41 +960,6 @@ mod tests {
assert_eq!(names, ["A.txt"]); 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)] #[cfg(windows)]
#[test] #[test]
fn accept_does_not_write_into_the_checkouts_git_folder_through_its_short_name() { fn accept_does_not_write_into_the_checkouts_git_folder_through_its_short_name() {
+1 -2
View File
@@ -12,8 +12,7 @@ mod up_to_date;
pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint}; pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint};
pub use launch_resources::process_launch_resources; pub use launch_resources::process_launch_resources;
pub use single::{ pub use single::{
SingleFileTarget, compile_file_plan, find_javac, javac_step, module_classpath, single_file_classpath, SingleFileTarget, compile_file_plan, find_javac, javac_step, single_file_classpath, single_file_target,
single_file_target,
}; };
pub use up_to_date::{menu_up_to_date, record_menu_build}; pub use up_to_date::{menu_up_to_date, record_menu_build};
+3 -210
View File
@@ -4,21 +4,12 @@
//! walks the whole reactor; compiling one file only needs that file's module //! walks the whole reactor; compiling one file only needs that file's module
//! outputs plus its dependency classpath, which is much faster to assemble. //! outputs plus its dependency classpath, which is much faster to assemble.
use super::*; use super::*;
use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::process::Command as ProcCommand; use std::process::Command as ProcCommand;
/// Output file of the `dependency:build-classpath` probe, relative to each module. /// Output file of the `dependency:build-classpath` probe, relative to each module.
const CP_FILE: &str = "target/ide-compile-cp.txt"; 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. /// A `.java` source and the module build that compiles it.
pub struct SingleFileTarget { pub struct SingleFileTarget {
/// Absolute module directory. /// 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 }) 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`. /// `javac` for the module: its `JAVA_HOME`, else `PATH`.
pub fn find_javac(env: &BTreeMap<String, String>) -> String { pub fn find_javac(env: &BTreeMap<String, String>) -> String {
let home = env.get("JAVA_HOME").map(Path::new); 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, /// `mvn dependency:build-classpath` (which resolves without compiling anything,
/// so `-am` stays cheap). Entries are platform-separated (`;` on Windows). /// so `-am` stays cheap). Entries are platform-separated (`;` on Windows).
pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFileTarget) -> IdeResult<String> { 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(); let mut entries: Vec<String> = Vec::new();
// The module's own classes first: the file almost always uses classes next to it. // 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"] }; 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"]); args.extend(["-pl", target.pl.as_str(), "-am"]);
} }
let output_arg = format!("-Dmdep.outputFile={probe}"); let output_arg = format!("-Dmdep.outputFile={probe}");
let scope_arg = format!("-Dmdep.includeScope={scope}"); args.extend(["dependency:build-classpath", output_arg.as_str(), "-Dmdep.includeScope=test", "-q"]);
// 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"]);
let top_dir = subdir(root, &target.top.dir); let top_dir = subdir(root, &target.top.dir);
let status = ProcCommand::new(&target.top.command) let status = ProcCommand::new(&target.top.command)
.args(&args) .args(&args)
@@ -239,10 +119,8 @@ fn classpath(
.output() .output()
.map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?; .map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?;
if !status.status.success() { if !status.status.success() {
let stdout = String::from_utf8_lossy(&status.stdout);
let stderr = String::from_utf8_lossy(&status.stderr); let stderr = String::from_utf8_lossy(&status.stderr);
// Maven reports reactor/dependency failures on stdout, not only stderr. let tail: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
let tail: Vec<&str> = stdout.lines().chain(stderr.lines()).filter(|l| !l.trim().is_empty()).collect();
let tail = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n"); 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}"))); return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
} }
@@ -427,7 +305,7 @@ mod tests {
let script = root.join("mvnw"); let script = root.join("mvnw");
fs::write( fs::write(
&script, &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(); .unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).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/a.jar"), "{cp}");
assert!(cp.contains("/repo/b.jar"), "{cp}"); assert!(cp.contains("/repo/b.jar"), "{cp}");
assert!(cp.split(separator).count() >= 3, "{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");
} }
} }
+21 -206
View File
@@ -712,27 +712,18 @@ pub(crate) fn launch_inputs(
// Profile activation may depend on the presence of an otherwise unrelated file. // Profile activation may depend on the presence of an otherwise unrelated file.
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing"); let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
if input || activation { if input || activation {
// A source root under Maven's build directory is generated by the build itself. // Other properties can be overridden by -D, profiles or settings.
// Skip it without resolving the property: `<build><directory>` may be custom, and // Without Maven's effective roots, conservatively disable reuse for them.
// hashing its rewritten files would make every launch look changed. let value = el
if !(input && el.text.contains("${project.build.directory}")) { .text
// Other properties can be overridden by -D, profiles or settings. Without .trim()
// Maven's effective model, leave them unresolved to conservatively disable reuse. .replace("${project.basedir}", &dir.to_string_lossy())
let value = el .replace("${basedir}", &dir.to_string_lossy());
.text if value.contains("${") {
.trim() return None;
.replace("${project.basedir}", &dir.to_string_lossy()) }
.replace("${basedir}", &dir.to_string_lossy()); if !value.is_empty() {
if value.contains("${") { out.push(dir.join(value));
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);
}
}
} }
} }
for child in &el.children { for child in &el.children {
@@ -1689,15 +1680,6 @@ pub enum PomGotoKind {
ManagedDependency, ManagedDependency,
/// A `<pluginManagement>` entry providing the plugin's version. /// A `<pluginManagement>` entry providing the plugin's version.
ManagedPlugin, 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`. /// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`.
@@ -1777,20 +1759,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 /// the local repository when it is not in the project). `None` when there is no
/// resolvable declaration here. /// resolvable declaration here.
pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<PathBuf>) -> Option<PomGoto> { 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 root = xml::parse(text).filter(|e| e.name == "project")?;
let at = offset_at(text, pos); let at = offset_at(text, pos);
if let Some((name, _, _)) = property_ref_at(text, at) if let Some((name, _, _)) = property_ref_at(text, at)
@@ -1801,56 +1769,15 @@ pub fn goto_declaration_in(
// A `<dependency>` / `<plugin>` in a declaration position: its version may come // A `<dependency>` / `<plugin>` in a declaration position: its version may come
// from a management entry of this POM or an ancestor's. // from a management entry of this POM or an ancestor's.
let elements = element_path(&root, at); 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")?; 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") {
if !elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") { return None; // Management entries are declarations; there is nothing to go to.
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);
}
} }
artifact_target(text, path, e, local_repo, modules) let kind = match e.name.as_str() {
} "dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
/// The artifact's own POM for a dependency or plugin element `e`. };
fn artifact_target( managed_target(text, path, e, kind, local_repo)
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 }
} }
/// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs, /// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
@@ -2149,52 +2076,6 @@ mod tests {
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table"); 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) { fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel); let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::create_dir_all(p.parent().unwrap()).unwrap();
@@ -3206,9 +3087,9 @@ mod tests {
let text = std::fs::read_to_string(&path).unwrap(); let text = std::fs::read_to_string(&path).unwrap();
// The property is declared nowhere along the chain. // The property is declared nowhere along the chain.
assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None); 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); 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_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap(); let parent_text = std::fs::read_to_string(&parent_path).unwrap();
assert_eq!( assert_eq!(
@@ -3220,72 +3101,6 @@ mod tests {
assert_eq!(goto_declaration("<project></project>", &path, Pos::new(0, 3), None), None); 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] #[test]
fn a_property_reference_after_non_ascii_text_is_still_found() { fn a_property_reference_after_non_ascii_text_is_still_found() {
let text = format!( let text = format!(
+7 -24
View File
@@ -393,13 +393,11 @@ fn scan_versions(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletion
out out
} }
/// The repository directory of `coordinates` (a group id, or a group and an artifact /// The repository directory of the `/`-separated `coordinates` (a group id, or
/// id): the group's dots are directories, an artifact id is one directory whatever dots /// a group and an artifact id), each segment validated against the layout's
/// it has (`org/eclipse/platform/org.eclipse.core.runtime`). Each segment is validated /// alphabet so POM text cannot walk outside `repo`.
/// against the layout's alphabet so POM text cannot walk outside `repo`.
fn repo_dir(repo: &Path, coordinates: &[&str]) -> Option<PathBuf> { fn repo_dir(repo: &Path, coordinates: &[&str]) -> Option<PathBuf> {
let (group, artifact) = coordinates.split_first()?; let segments: Vec<&str> = coordinates.iter().flat_map(|c| c.split('.')).collect();
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))) segments.iter().all(|s| segment(s)).then(|| segments.iter().fold(repo.to_path_buf(), |p, s| p.join(s)))
} }
@@ -462,7 +460,6 @@ mod tests {
"org/example/lib/2.0-beta-1/lib-2.0-beta-1.jar", "org/example/lib/2.0-beta-1/lib-2.0-beta-1.jar",
"org/example/lib/2.0/lib-2.0.pom", "org/example/lib/2.0/lib-2.0.pom",
"net/nu/netty/4.1.100/netty-4.1.100.jar", "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. // Failed downloads (a version typed while editing): tracking files only, not installed.
"org/example/lib/2/lib-2.jar.lastUpdated", "org/example/lib/2/lib-2.jar.lastUpdated",
"org/example/lib/3.0/lib-3.0.pom.lastUpdated", "org/example/lib/3.0/lib-3.0.pom.lastUpdated",
@@ -573,10 +570,9 @@ mod tests {
}; };
// Only directories holding artifacts are group ids: `net` and `org` // Only directories holding artifacts are group ids: `net` and `org`
// alone never receive an install, their deeper levels do. // 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.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options(""), [&["net.nu"][..], &org].concat()); assert_eq!(options("org"), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org"), org); assert_eq!(options("org."), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org."), org);
assert_eq!(options("org.spr"), Vec::<String>::new()); assert_eq!(options("org.spr"), Vec::<String>::new());
assert_eq!(options("net."), ["net.nu"]); assert_eq!(options("net."), ["net.nu"]);
} }
@@ -627,19 +623,6 @@ mod tests {
}; };
assert_eq!(options(""), ["2.0", "2.0-beta-1", "1.10", "1.9.1", "1.0"]); 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"]); 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] #[test]
-9
View File
@@ -270,13 +270,6 @@ fn run(
/// The first error lines of a failed build, for the tool window. /// The first error lines of a failed build, for the tool window.
pub(crate) fn error_summary(text: &str) -> String { 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 let lines: Vec<&str> = text
.lines() .lines()
.filter(|l| { .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"; 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(text), "Failed to execute goal");
assert_eq!(error_summary("one\nlast line\n\n"), "last line"); 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] #[test]
-15
View File
@@ -27,13 +27,6 @@ pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
imp::set_text(text, owner) 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`. /// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20; 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<()> { pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread"); debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| { autoreleasepool(|_| {
-8
View File
@@ -8,18 +8,13 @@
use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange}; use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use std::collections::BTreeMap;
use std::sync::Arc; 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)] #[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] #[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum McpUiRequest { pub enum McpUiRequest {
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> }, Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> }, 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)] #[derive(Clone, Debug, Serialize, Deserialize, Type)]
@@ -77,9 +72,6 @@ pub enum IdeEvent {
WorkspaceEditApplied { WorkspaceEditApplied {
id: u32, id: u32,
paths: Vec<crate::IpcPath>, 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 /// An open document's language changed (Settings › File Types, Override File
/// Type), or its UI mode (a framework's root marker appeared or went). /// Type), or its UI mode (a framework's root marker appeared or went).
+1 -52
View File
@@ -76,8 +76,7 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
text text
} }
/// The `encoding_rs` encoding of a Windows code page, when it has one. fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
Some(match cp { Some(match cp {
65001 => encoding_rs::UTF_8, 65001 => encoding_rs::UTF_8,
936 => encoding_rs::GBK, 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 STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
const FILE_ENCODING: &str = "file.encoding"; const FILE_ENCODING: &str = "file.encoding";
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS"; const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
-13
View File
@@ -77,8 +77,6 @@ pub enum AutoUpload {
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
pub struct DeploymentSettings { pub struct DeploymentSettings {
pub servers: Vec<DeploymentServer>, pub servers: Vec<DeploymentServer>,
/// Server groups: Upload to… on a group uploads to each of its servers.
pub groups: Vec<DeploymentGroup>,
/// Server used by Upload / Download / Sync and automatic upload; empty = none. /// Server used by Upload / Download / Sync and automatic upload; empty = none.
pub default_server: String, pub default_server: String,
pub auto_upload: AutoUpload, pub auto_upload: AutoUpload,
@@ -94,7 +92,6 @@ impl Default for DeploymentSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
servers: Vec::new(), servers: Vec::new(),
groups: Vec::new(),
default_server: String::new(), default_server: String::new(),
auto_upload: AutoUpload::Never, auto_upload: AutoUpload::Never,
delete_remote: false, delete_remote: false,
@@ -106,16 +103,6 @@ impl Default for DeploymentSettings {
} }
} }
/// Named set of deployment servers that files are uploaded to together.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentGroup {
pub id: String,
pub name: String,
/// [`DeploymentServer::id`]s, in upload order.
pub servers: Vec<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum DeploymentType { pub enum DeploymentType {
+1 -30
View File
@@ -288,7 +288,7 @@ pub enum NewTaskApprovals {
} }
/// Agents › MCP Server: agents use the IDE's built-in tools. /// 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")] #[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings { pub struct McpServerSettings {
/// Serve the tools on 127.0.0.1 while the IDE runs. /// Serve the tools on 127.0.0.1 while the IDE runs.
@@ -296,35 +296,6 @@ pub struct McpServerSettings {
/// Run the tools that change the project or start processes without asking first /// 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. /// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
pub run_without_confirmation: bool, 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. /// Absent profiles retain automatic discovery for existing projects.
+1 -1
View File
@@ -753,7 +753,7 @@ impl Pool {
return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle); return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle);
} }
if source.driver == DbDriver::Snowflake { 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 timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
let init = Arc::new(setup.statements); let init = Arc::new(setup.statements);
+9 -12
View File
@@ -1491,21 +1491,18 @@ impl DbManager {
let Some(session) = session else { return Ok(()) }; let Some(session) = session else { return Ok(()) };
let mut held = session.lock().await; let mut held = session.lock().await;
let Some(mut conn) = held.take() else { return Ok(()) }; let Some(mut conn) = held.take() else { return Ok(()) };
if let Err(e) = conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await { let result = async {
// Discarding a Snowflake connection doesn't end its session, which the data source's conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?;
// other targets share: the transaction stays open, so it stays held to be ended again. conn.restore_default().await
if matches!(conn, conn::Held::Snowflake(..)) { }
*held = Some(conn); .await;
} else { match result {
Ok(()) => Ok(()),
Err(e) => {
conn.discard().await; 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)] #[allow(clippy::too_many_arguments)]
+24 -94
View File
@@ -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 /// Live sessions by their connection settings, so targets opened with the same settings share one.
/// databases, the tree, consoles) opened with the same settings share one.
static SESSIONS: LazyLock<parking_lot::Mutex<HashMap<String, Weak<State>>>> = static SESSIONS: LazyLock<parking_lot::Mutex<HashMap<String, Weak<State>>>> =
LazyLock::new(|| parking_lot::Mutex::new(HashMap::new())); LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
@@ -107,15 +106,6 @@ struct Lane {
context: parking_lot::Mutex<Option<String>>, 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 CONSOLE: usize = 0;
const METADATA: usize = 1; const METADATA: usize = 1;
@@ -261,12 +251,7 @@ fn gunzip(bytes: Vec<u8>) -> IdeResult<Vec<u8>> {
} }
impl SnowflakePool { impl SnowflakePool {
pub(crate) async fn connect( pub(crate) async fn connect(source: &DataSource, secret: Option<&str>, setup: Setup) -> IdeResult<Self> {
source: &DataSource,
secret: Option<&str>,
setup: Setup,
root: Option<&std::path::Path>,
) -> IdeResult<Self> {
let settings = source.snowflake(); let settings = source.snowflake();
let endpoint = account::endpoint(source)?; let endpoint = account::endpoint(source)?;
let user = source.user.trim(); let user = source.user.trim();
@@ -317,15 +302,9 @@ impl SnowflakePool {
lane: CONSOLE, lane: CONSOLE,
}; };
// Everything that makes a session what it is, the secret included (hashed: the key is only compared). // 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( let key = blake3::hash(
format!( format!(
"{:?}\0{}\0{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}", "{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
root,
source.id,
api.address(), api.address(),
settings.auth, settings.auth,
secret.unwrap_or_default(), secret.unwrap_or_default(),
@@ -657,18 +636,13 @@ impl SnowflakePool {
() = self.closed.cancelled() => return Err(closed()), () = self.closed.cancelled() => return Err(closed()),
turn = self.lane().turn.lock() => turn, turn = self.lane().turn.lock() => turn,
}; };
// A `USE` needs no schema to run in, and may be what makes the schema reachable. Ending a // A `USE` needs no schema to run in, and may be what makes the schema reachable.
// transaction needs none either, and must not fail because the schema is gone meanwhile. if first_word(sql) != "USE" {
let context = match first_word(sql).as_str() { self.enter(schema, cancel).await?;
"USE" | "COMMIT" | "ROLLBACK" => None, }
_ => {
self.enter(schema, cancel).await?;
self.context(schema)
}
};
// These can move the session to another database or schema. // These can move the session to another database or schema.
let moves = matches!(first_word(sql).as_str(), "USE" | "CALL" | "EXECUTE"); 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 { if moves {
*self.lane().context.lock() = None; *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().as_deref() != Some(wanted.as_str())
{ {
*self.lane().context.lock() = None; *self.lane().context.lock() = None;
// Refused (the session's role may not use this database or schema): the statement match self.statement(&wanted, 0, 0, cancel).await {
// does not run, since the session is still wherever it was, which may be another Ok(_) => {}
// target's database. Tried again with the next statement. // The session's role may not use this database or schema (after `USE ROLE`, or a
if let Err(error) = self.statement(&wanted, 0, 0, Lost::Enter(None), cancel).await { // schema that is gone). The statement still runs, in the session's own context:
return Err(IdeError { // it may be the `USE` that leads back, or name its objects in full. Asked again
message: format!("{wanted} failed, so the statement was not run: {}", error.message), // once a statement moves the session.
..error 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); *self.lane().context.lock() = Some(wanted);
} }
@@ -704,11 +680,11 @@ impl SnowflakePool {
let _turn = self.lane().turn.lock().await; let _turn = self.lane().turn.lock().await;
let cancel = CancellationToken::new(); let cancel = CancellationToken::new();
self.enter(None, &cancel).await?; 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 changes = async {
let mut total = 0; let mut total = 0;
for (sql, expect_one) in statements { 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 { if *expect_one && changed != 1 {
return Err(IdeError::invalid(format!( return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {changed}; nothing was saved" "{sql}: expected to change 1 row, changed {changed}; nothing was saved"
@@ -721,24 +697,22 @@ impl SnowflakePool {
.await; .await;
match changes { match changes {
Ok(total) => { Ok(total) => {
self.statement("COMMIT", 0, 0, Lost::Fail, &cancel).await?; self.statement("COMMIT", 0, 0, &cancel).await?;
Ok(total) Ok(total)
} }
Err(error) => { Err(error) => {
let _ = self.statement("ROLLBACK", 0, 0, Lost::Fail, &cancel).await; let _ = self.statement("ROLLBACK", 0, 0, &cancel).await;
Err(error) Err(error)
} }
} }
} }
/// One statement on the session; the caller holds the session's turn. `lost`: what to do when /// One statement on the session; the caller holds the session's turn.
/// the session is gone and a new one is signed in to.
async fn statement( async fn statement(
&self, &self,
sql: &str, sql: &str,
skip: usize, skip: usize,
max_rows: usize, max_rows: usize,
lost: Lost<'_>,
cancel: &CancellationToken, cancel: &CancellationToken,
) -> IdeResult<QueryResult> { ) -> IdeResult<QueryResult> {
let started = Instant::now(); let started = Instant::now();
@@ -769,24 +743,7 @@ impl SnowflakePool {
Ok(Outcome::Done(result)) => return Ok(*result), Ok(Outcome::Done(result)) => return Ok(*result),
Ok(Outcome::Expired { gone }) if !renewed => { Ok(Outcome::Expired { gone }) if !renewed => {
renewed = true; renewed = true;
if !gone { tokens = if gone { self.login().await? } else { self.renew(&tokens).await? };
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?;
}
} }
Ok(Outcome::Expired { .. }) => { Ok(Outcome::Expired { .. }) => {
return Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect")); 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( async fn run(
&self, &self,
sql: &str, sql: &str,
+16 -106
View File
@@ -407,64 +407,13 @@ async fn a_lost_session_signs_in_again() {
let (listener, port) = bind().await; let (listener, port) = bind().await;
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#; 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 again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }], let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(one()), ok(CLOSED_OK)]);
"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 pool = Pool::connect(&source(port), None, None).await.unwrap(); let pool = Pool::connect(&source(port), None, None).await.unwrap();
pool.execute("select 1", 5, &CancellationToken::new()).await.unwrap(); pool.execute("select 1", 5, &CancellationToken::new()).await.unwrap();
pool.close().await; pool.close().await;
let seen = server.await.unwrap(); let seen = server.await.unwrap();
assert!(seen[2].line().starts_with("POST /session/v1/login-request?"), "{}", seen[2].line()); 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].header("authorization"), Some("Snowflake Token=\"session-token-3\""));
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");
} }
#[tokio::test] #[tokio::test]
@@ -765,7 +714,7 @@ async fn a_console_schema_is_switched_with_its_statement_and_only_when_needed()
} }
#[tokio::test] #[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 = || { let status = || {
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }], json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } }) "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 refused = r#"{"success":false,"code":"002043","message":"SQL compilation error:\nObject does not exist, or operation cannot be performed."}"#;
let (listener, port) = bind().await; let (listener, port) = bind().await;
// use role | the schema is refused, the statement is not sent | asked again, refused again | // use role | the schema is refused once, the statement runs | the next one does not ask again |
// use role back, the schema is entered, the statement runs // a role that is not granted | use role back, the schema is entered again
let replies = vec![ let replies = vec![
ok(LOGIN_OK), ok(LOGIN_OK),
ok(status()), ok(status()),
ok(refused), ok(refused),
ok(one()),
ok(one()),
ok(refused), ok(refused),
ok(status()), ok(status()),
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 pool = Pool::connect(&source(port), None, None).await.unwrap();
let cancel = CancellationToken::new(); let cancel = CancellationToken::new();
pool.execute("use role auditor", 0, &cancel).await.unwrap(); pool.execute("use role auditor", 0, &cancel).await.unwrap();
let error = pool.execute("delete from t", 1, &cancel).await.unwrap_err(); pool.execute("select 1", 1, &cancel).await.unwrap();
assert!( pool.execute("select 2", 1, &cancel).await.unwrap();
error.message.contains("USE SCHEMA") let missing = pool.execute("use role nobody", 0, &cancel).await.unwrap_err();
&& error.message.contains("was not run") assert!(missing.message.contains("002043") && missing.message.contains("granted"), "{}", missing.message);
&& error.message.contains("002043"),
"{}",
error.message
);
assert!(pool.execute("select 2", 1, &cancel).await.is_err());
pool.execute("use role analyst", 0, &cancel).await.unwrap(); pool.execute("use role analyst", 0, &cancel).await.unwrap();
pool.execute("select 3", 1, &cancel).await.unwrap(); pool.execute("select 3", 1, &cancel).await.unwrap();
pool.close().await; pool.close().await;
let sent: Vec<String> = 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\""; 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] #[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()); 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(&copy, 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] #[tokio::test]
async fn metadata_runs_on_its_own_session_beside_a_busy_console() { async fn metadata_runs_on_its_own_session_beside_a_busy_console() {
let (listener, port) = bind().await; let (listener, port) = bind().await;
-4
View File
@@ -60,8 +60,6 @@ pub struct Host {
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>, plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
pub mcp_reports: mcp::Reports, pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests, 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 trust: trust::TrustStore,
pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>, pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
pub config_dir: PathBuf, pub config_dir: PathBuf,
@@ -143,7 +141,6 @@ impl Host {
mcp_executable: RwLock::default(), mcp_executable: RwLock::default(),
mcp_reports: Default::default(), mcp_reports: Default::default(),
mcp_requests: Default::default(), mcp_requests: Default::default(),
mcp_agent_runs: Default::default(),
trust: trust::TrustStore::load(&config_dir, &data_dir), trust: trust::TrustStore::load(&config_dir, &data_dir),
mcp_approvals: Arc::new(trust::McpServerApprovals::load(&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_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
@@ -272,7 +269,6 @@ impl Host {
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> { fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
self.mcp_reports.forget_window(window); self.mcp_reports.forget_window(window);
self.mcp_requests.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)); self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() { for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request); self.cancel_request(request);
-176
View File
@@ -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"
);
}
}
+3 -3
View File
@@ -87,7 +87,7 @@ async fn create_or_replace(call: &Call, path: PathBuf, text: Option<String>, ove
return Ok(()); return Ok(());
} }
let edit = crate::ops::lsp::prepare_workspace_edit(&call.host, call.window(), edit).await?; 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). /// The open document of `path`, or a transient one (`true`: close it after use).
@@ -317,7 +317,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
preconditions: Vec::new(), preconditions: Vec::new(),
}; };
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?; 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 } => { FileOp::Update { path: first, move_to, hunks, or } => {
// A plain diff's other names count when its `+++` path does not exist. // A plain diff's other names count when its `+++` path does not exist.
@@ -400,7 +400,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() }); edit.files.push(FileEdit::Rename { from: path.into(), to: to.into() });
} }
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?; 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 { if dirty {
notes.push(format!("{shown}: patched the unsaved editor buffer")); notes.push(format!("{shown}: patched the unsaved editor buffer"));
} }
+1 -1
View File
@@ -385,7 +385,7 @@ targetIndex, or narrow the call with a contextSnippet that shows the one you mea
_ => None, _ => None,
}); });
let (h, w) = (host.clone(), window.to_owned()); 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 { if let Err(e) = written {
let hint = format!("The rename stopped while it wrote. {} Read the files again before you retry.", e.message); 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)); return Ok(RenameResult::failed("write_failed", hint).with_symbol(symbol));
+1 -1
View File
@@ -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!({ json!({
"severity": d.severity, "description": d.message, "severity": d.severity, "description": d.message,
"lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(), "lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(),
+9 -34
View File
@@ -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. "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. Can read text sources inside jar/zip archives; compiled class files are not decompiled.
Returns numbered lines (1-indexed) as text. 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 By default, reads up to 2000 lines starting from the beginning of the file.
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.
The maximum accepted limit is 5000 lines.", The maximum accepted limit is 5000 lines.",
|call| async move { |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 offset = positive(&call, "offset", 1)?;
let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?; let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?;
if limit > MAX_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?; 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)) .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 /// Lines `start..` of `text` as `L<n>: <line>`; when more than `max_lines` remain, the first
/// exactly the lines from `start` on, then how many lines remain; otherwise, when more than /// and the last halves of the budget with a `…N lines truncated…` marker between them.
/// `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. /// 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 lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let count = lines.len(); let count = lines.len();
if start > count { if start > count {
@@ -93,15 +86,6 @@ fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> Ide
if available <= max_lines { if available <= max_lines {
return Ok(range(start, count)); 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 head = max_lines / 2;
let tail = max_lines - head; let tail = max_lines - head;
let head_end = start + head - 1; let head_end = start + head - 1;
@@ -261,7 +245,7 @@ mod tests {
use super::*; use super::*;
fn slice(text: &str, start: usize, max: usize) -> String { fn slice(text: &str, start: usize, max: usize) -> String {
render_slice(text, start, max, false).unwrap() render_slice(text, start, max).unwrap()
} }
#[test] #[test]
@@ -270,27 +254,18 @@ mod tests {
assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: "); assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: ");
assert_eq!(slice("", 1, 10), "L1: "); assert_eq!(slice("", 1, 10), "L1: ");
assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c"); 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("", 2, 10).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("a\nb", 3, 10).unwrap_err().message.contains("offset exceeds file length"));
} }
#[test] #[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"); 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, 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, 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"); 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] #[test]
fn cuts_long_lines_at_utf16_units() { fn cuts_long_lines_at_utf16_units() {
let long = "x".repeat(2100); let long = "x".repeat(2100);
+1 -4
View File
@@ -1,9 +1,8 @@
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`. //! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
mod check;
mod edit; mod edit;
mod editors; mod editors;
mod requests; mod requests;
pub(crate) mod run; mod run;
mod vcs; mod vcs;
pub use requests::Requests; pub use requests::Requests;
mod files; mod files;
@@ -17,7 +16,6 @@ use crate::Host;
use ide_core::{CancellationToken, IdeError}; use ide_core::{CancellationToken, IdeError};
use ide_mcp::{ToolCallError, ToolRegistry}; use ide_mcp::{ToolCallError, ToolRegistry};
pub use reports::{FileProblems, FocusedEditor, Reports}; 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::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -32,7 +30,6 @@ fn tools() -> Vec<tool::Tool> {
let mut tools = files::tools(); let mut tools = files::tools();
tools.extend(search::tools()); tools.extend(search::tools());
tools.extend(editors::tools()); tools.extend(editors::tools());
tools.push(check::check_files());
tools.push(symbols::get_symbol_info()); tools.push(symbols::get_symbol_info());
tools.push(symbols::find_references()); tools.push(symbols::find_references());
tools.extend(edit::tools()); tools.extend(edit::tools());
File diff suppressed because it is too large. Load diff
-418
View File
@@ -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"));
}
}
+3 -2
View File
@@ -15,6 +15,7 @@ use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
const DEFAULT_TIMEOUT_MS: usize = 60_000; const DEFAULT_TIMEOUT_MS: usize = 60_000;
const MAX_TIMEOUT_MS: usize = 3_600_000;
const MAX_PROBLEMS: usize = 300; const MAX_PROBLEMS: usize = 300;
/// Console lines attached to a failure whose messages were not parsed. /// Console lines attached to a failure whose messages were not parsed.
const CONSOLE_LINES: usize = 200; 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 \ 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 \ 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 \ `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 { |call| async move {
let files: Vec<String> = let files: Vec<String> =
call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect(); call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect();
let rebuild = call.flag("rebuild", false)? && files.is_empty(); let rebuild = call.flag("rebuild", false)? && files.is_empty();
let kind = if rebuild { BuildKind::Rebuild } else { BuildKind::Build }; 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 targets: Vec<Option<PathBuf>> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new(); let mut dirs: Vec<PathBuf> = Vec::new();
for f in &files { for f in &files {
+3 -1
View File
@@ -19,6 +19,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 10_000; 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. /// Output of a failed before-launch process kept for the error message.
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000; const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
/// A before-launch build gets this long. /// 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)?; let wait = call.flag("waitForExit", true)?;
// As JetBrains: `timeout` is ignored without waiting, and 0 returns without waiting. // 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 envs = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
let configs = load_configs(&call).await?; let configs = load_configs(&call).await?;
let mut config = match &target { let mut config = match &target {
+5 -14
View File
@@ -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 build;
mod configs; mod configs;
mod output; mod output;
@@ -13,22 +11,15 @@ use ide_core::IdeResult;
use std::time::Duration; use std::time::Duration;
pub fn tools() -> Vec<Tool> { pub fn tools() -> Vec<Tool> {
let mut tools = vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()]
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()];
tools.extend(agent_tools::tools());
tools
} }
/// Longest wait: far enough to be no limit, near enough not to overflow a deadline. /// The `timeout` argument: `default` when absent, at most `max` milliseconds. As in JetBrains
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
/// (`withTimeoutOrNull`), zero or a negative value times out at once. /// (`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")? { Ok(match call.opt_i64("timeout")? {
None => Duration::from_millis(default as u64), None => Duration::from_millis(default as u64),
Some(n) if n <= 0 => Duration::ZERO, 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)),
}) })
} }
+2 -42
View File
@@ -434,23 +434,10 @@ fn cwd_of(root: &Path, c: &RunConfig) -> String {
cwd.trim_end_matches('/').to_owned() cwd.trim_end_matches('/').to_owned()
} }
/// The directory a config covers: a Cargo workspace member's own directory (its `cargo test -p` /// Configs whose working directory most specifically contains `rel` (ties in their original order).
/// 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).
fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> { fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> {
let score = |c: &RunConfig| -> Option<usize> { 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()) } if cwd.is_empty() { Some(0) } else { (rel == cwd || rel.starts_with(&format!("{cwd}/"))).then_some(cwd.len()) }
}; };
let scored: Vec<(&RunConfig, usize)> = configs.iter().filter_map(|c| score(c).map(|s| (*c, s))).collect(); let 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::"]); 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] #[test]
fn cargo_test_args_get_one_separator() { fn cargo_test_args_get_one_separator() {
let root = Path::new("/p"); let root = Path::new("/p");
+2 -131
View File
@@ -38,14 +38,6 @@ const LINE_WINDOW: usize = 500;
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \ 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."; 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 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> { pub fn tools() -> Vec<Tool> {
vec![search_text(), search_regex(), search_symbol(), search_file()] 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. "Searches for a text substring within project files.
Use this tool for fast text search with match coordinates. Use this tool for fast text search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive). 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. Paths are glob patterns relative to the project root.
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].", 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::required("q", ParamType::String, "Text to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION)) .param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION)) .param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema()) .output(result_schema())
} }
@@ -77,8 +66,6 @@ fn search_regex() -> Tool {
"Searches for regex matches within project files. "Searches for regex matches within project files.
Use this tool when you need regex search with match coordinates. Use this tool when you need regex search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive). 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. The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case.
Paths are glob patterns relative to the project root. 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::required("q", ParamType::String, "Regex pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION)) .param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION)) .param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema()) .output(result_schema())
} }
@@ -98,7 +84,6 @@ fn search_symbol() -> Tool {
"Searches for symbols (classes, methods, fields). "Searches for symbols (classes, methods, fields).
Use this tool for semantic lookup by identifier fragments. Use this tool for semantic lookup by identifier fragments.
Results include match coordinates when available (1-based line/column, end exclusive). 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. Paths are glob patterns relative to the project root.
By default this searches project symbols only. 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.", with include_external=true.",
)) ))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION)) .param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema()) .output(result_schema())
} }
@@ -161,13 +145,6 @@ struct SearchItem {
name: Option<String>, name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
container: Option<String>, 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 { impl SearchItem {
@@ -205,9 +182,7 @@ fn result_schema() -> Value {
"type": "object", "type": "object",
"properties": { "properties": {
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int, "filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
"name": string, "container": string, "lineText": string, "name": string, "container": string,
"contextBefore": { "type": "array", "items": string },
"contextAfter": { "type": "array", "items": string },
}, },
"required": ["filePath"], "required": ["filePath"],
} }, } },
@@ -232,87 +207,6 @@ fn query(call: &Call, empty: &str) -> IdeResult<String> {
Ok(q) 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))) = &current 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 ---------------------------------------------------------------- // --- path globs ----------------------------------------------------------------
/// The `paths` argument: project-relative globs, `!` excludes. /// The `paths` argument: project-relative globs, `!` excludes.
@@ -522,7 +416,6 @@ struct TextHit {
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> { 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 pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
let limit = limit(&call, DEFAULT_LIMIT)?; let limit = limit(&call, DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?; let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
// Globs Find in Files cannot take are applied afterwards, so the engine must not // Globs Find in Files cannot take are applied afterwards, so the engine must not
// stop at `limit` matches the filter may drop. // 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; let more = hits.len() > limit || summary.limit_hit || timed_out;
hits.truncate(limit); hits.truncate(limit);
extend_spanning(&call, &query, &mut hits, spanning).await; extend_spanning(&call, &query, &mut hits, spanning).await;
let mut items: Vec<SearchItem> = hits let items = hits
.into_iter() .into_iter()
.map(|h| SearchItem { .map(|h| SearchItem {
file_path: h.file_path, file_path: h.file_path,
@@ -612,9 +505,6 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
..Default::default() ..Default::default()
}) })
.collect(); .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); let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, 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 started = Instant::now();
let q = query(&call, "Search query is empty")?; let q = query(&call, "Search query is empty")?;
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?; let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?; let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let external = call.flag("include_external", false)?; let external = call.flag("include_external", false)?;
let host = call.host(); let host = call.host();
@@ -886,13 +775,6 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
} }
let more = items.len() > limit || timed_out; let more = items.len() > limit || timed_out;
items.truncate(limit); 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 indexing = indexing_before || project.index.status().indexing;
let partial_result_reason = if indexing { let partial_result_reason = if indexing {
Some(INDEXING_PARTIAL_RESULT_REASON) Some(INDEXING_PARTIAL_RESULT_REASON)
@@ -987,17 +869,6 @@ mod tests {
assert_eq!(byte_at("ab", 5), None); 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] #[test]
fn path_scopes() { fn path_scopes() {
let (_dir, root) = root(); let (_dir, root) = root();
+3 -49
View File
@@ -313,11 +313,10 @@ pub async fn build_sync(
if tool == BuildTool::Gradle { if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them. // The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope(); project.refresh_index_scope();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
let p = project.clone(); let p = project.clone();
tokio::task::spawn_blocking(move || p.refresh_repositories()); 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 if tool == BuildTool::Maven
&& model.maven.is_some() && model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge && let Some(bridge) = &project.kotlin_bridge
@@ -456,8 +455,7 @@ pub async fn maven_dependency_declaration(
/// absolute for a parent POM opened from the local repository): the definition of the /// absolute for a parent POM opened from the local repository): the definition of the
/// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>` /// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>`
/// entry providing the dependency's / plugin's version, along the parent chain (into /// 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 /// the local repository when a parent is not in the project). `None` when nothing
/// POM (a project module's, or the local repository's). `None` when nothing
/// resolvable is under the caret. /// resolvable is under the caret.
pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> { pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> {
let project = host.project(window)?; let project = host.project(window)?;
@@ -468,19 +466,7 @@ pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos:
let settings = project.settings.effective(); let settings = project.settings.effective();
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings) let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo); .or_else(ide_build::maven::default_local_repo);
let modules = || { Ok(ide_build::maven::goto_declaration(&text, &path, pos, repo))
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))
}) })
.await .await
.map_err(join_err)? .map_err(join_err)?
@@ -985,38 +971,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] #[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() { fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
+27 -97
View File
@@ -96,19 +96,18 @@ async fn resolve_launch(
let cancel = cancel.clone(); let cancel = cancel.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let _external_build = external_build; let _external_build = external_build;
let local_repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
let built = if delegated { 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( let outputs = project.java_launch_snapshots.lock().build(
&project.root, &project.root,
&model, &model,
&module, &module,
cache::BuildOptions { test, policy, rebuild_on_jdt_errors }, cache::BuildOptions { test, policy },
local_repo.clone(), ide_sdk::apply::maven_local_repository(&project.root, &settings),
&cancel, &cancel,
)?; )?;
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot); 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 copies
} else if external { } else if external {
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?; let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
@@ -124,22 +123,10 @@ async fn resolve_launch(
Vec::new() Vec::new()
}; };
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect(); let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
if delegated { for p in std::mem::take(&mut info.class_paths) {
// m2e runs inside jdtls on the server JDK and can resolve a different if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
// Maven profile/classifier than the module build. Ask the same Maven class_paths.push(p);
// 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));
} }
info.class_paths = class_paths; info.class_paths = class_paths;
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test); apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
@@ -204,18 +191,21 @@ async fn resolve_launch(
/// With Settings › Maven › Delegate IDE build/run actions to Maven, javac's verdict replaces /// 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 /// 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. /// compiler, so a launch JDT reports errors for recompiles everything.
fn rebuild_on_jdt_errors(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> bool { fn delegated_policy(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> BuildPolicy {
prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make) 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.build = Some(if let Some(failure) = failure {
info.hints.push(failure); info.hints.push(failure);
JavaBuildStatus::Failed JavaBuildStatus::Failed
} else if authoritative { } else if clean {
// A clean rebuild or a fingerprint-verified snapshot is authoritative. JDT diagnostics // A clean external build recompiled every source; JDT's diagnostics still describe
// may describe an older output or its own classpath, so they must not mark this launch // the previous output (and reject sources javac accepts, e.g. a package mismatch).
// as failed after Maven has produced usable classes.
info.errors.clear(); info.errors.clear();
info.error_count = 0; info.error_count = 0;
JavaBuildStatus::Succeeded JavaBuildStatus::Succeeded
@@ -296,44 +286,6 @@ fn same_entry(a: &str, b: &str, windows: bool) -> bool {
a == b || entry_key(a, windows) == entry_key(b, windows) 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> { fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
use_snapshot_on(info, built, snapshot, cfg!(windows)) use_snapshot_on(info, built, snapshot, cfg!(windows))
} }
@@ -458,36 +410,14 @@ mod tests {
#[test] #[test]
fn delegated_launches_with_jdt_errors_recompile_with_maven() { fn delegated_launches_with_jdt_errors_recompile_with_maven() {
assert!(rebuild_on_jdt_errors(BuildPolicy::Make, true, 1)); assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
assert!(rebuild_on_jdt_errors(BuildPolicy::Automatic, true, 2)); assert_eq!(delegated_policy(BuildPolicy::Automatic, true, 2), BuildPolicy::Rebuild);
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, true, 0)); assert_eq!(delegated_policy(BuildPolicy::Make, true, 0), BuildPolicy::Make);
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, false, 1)); assert_eq!(delegated_policy(BuildPolicy::Make, false, 1), BuildPolicy::Make);
assert!(!rebuild_on_jdt_errors(BuildPolicy::MakeAndContinueOnErrors, true, 1)); assert_eq!(
} delegated_policy(BuildPolicy::MakeAndContinueOnErrors, true, 1),
BuildPolicy::MakeAndContinueOnErrors
#[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());
} }
#[test] #[test]
@@ -531,7 +461,7 @@ mod tests {
} }
#[test] #[test]
fn authoritative_external_result_clears_stale_jdt_errors() { fn clean_external_build_clears_stale_jdt_errors() {
let mut info = JavaLaunchInfo { let mut info = JavaLaunchInfo {
error_count: 7, error_count: 7,
errors: vec![ide_lsp::jdtls::JavaCompileError { errors: vec![ide_lsp::jdtls::JavaCompileError {
+4 -71
View File
@@ -14,10 +14,6 @@ pub(super) struct Outputs {
pub originals: Vec<PathBuf>, pub originals: Vec<PathBuf>,
#[serde(skip)] #[serde(skip)]
pub failure: Option<String>, 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 { impl Outputs {
@@ -46,9 +42,6 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
pub(super) struct BuildOptions { pub(super) struct BuildOptions {
pub test: bool, pub test: bool,
pub policy: BuildPolicy, 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. /// Owned by Project: running JVMs may still load superseded outputs until it drops.
@@ -82,7 +75,7 @@ impl Session {
cancel: &CancellationToken, cancel: &CancellationToken,
) -> IdeResult<Outputs> { ) -> IdeResult<Outputs> {
self.initialize(); self.initialize();
let BuildOptions { test, policy, rebuild_on_jdt_errors } = options; let BuildOptions { test, policy } = options;
let cache = &self.cache; let cache = &self.cache;
let record = let record =
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?; record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
@@ -122,14 +115,8 @@ impl Session {
&& outputs.reusable(&before, &snapshots) && outputs.reusable(&before, &snapshots)
{ {
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot"); tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
let mut outputs = previous.unwrap(); return Ok(previous.unwrap());
outputs.authoritative = true;
return Ok(outputs);
} }
// 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 // 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. // source still delegates to Maven to clean its old classes on the next launch.
fs::create_dir_all(record.parent().unwrap())?; fs::create_dir_all(record.parent().unwrap())?;
@@ -169,13 +156,7 @@ impl Session {
if cancel.is_cancelled() { if cancel.is_cancelled() {
return Err(IdeError::cancelled()); return Err(IdeError::cancelled());
} }
let mut outputs = Outputs { let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure };
fingerprint: None,
snapshot,
originals,
failure: built.failure,
authoritative: policy == BuildPolicy::Rebuild,
};
if outputs.failure.is_none() && before.is_some() && before == after { if outputs.failure.is_none() && before.is_some() && before == after {
outputs.fingerprint = before; outputs.fingerprint = before;
fs::create_dir_all(record.parent().unwrap())?; 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] #[test]
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() { fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -544,13 +483,7 @@ mod tests {
fs::write(snapshots.join("launch-file"), "keep").unwrap(); fs::write(snapshots.join("launch-file"), "keep").unwrap();
let record = cache.join("java-launch/records/deep/module/test.json"); let record = cache.join("java-launch/records/deep/module/test.json");
fs::create_dir_all(record.parent().unwrap()).unwrap(); fs::create_dir_all(record.parent().unwrap()).unwrap();
let outputs = Outputs { let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), failure: None };
fingerprint: None,
snapshot: retained.clone(),
originals: Vec::new(),
failure: None,
authoritative: false,
};
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap(); fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
let mut session = Session::new(cache); let mut session = Session::new(cache);
session.initialize(); session.initialize();
+12 -27
View File
@@ -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<()> { pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?; let project = host.project(window)?;
blocking(move || { 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) apply_edit(&project.documents, &edit)
}) })
.await .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> { pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult<WorkspaceEdit> {
let project = host.project(window)?; let project = host.project(window)?;
blocking(move || { blocking(move || {
+1 -20
View File
@@ -234,19 +234,7 @@ pub async fn start_request(
) -> IdeResult<RunInfo> { ) -> IdeResult<RunInfo> {
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default(); let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
let operation = config.extras.params.get("plugin.operation").cloned(); let operation = config.extras.params.get("plugin.operation").cloned();
let mut config = config; let result = start_inner(host, window, config, on_output, &cancel).await;
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);
}
if let Some(id) = request { if let Some(id) = request {
host.end_request(id); host.end_request(id);
} }
@@ -261,7 +249,6 @@ async fn start_inner(
window: &str, window: &str,
mut config: RunConfig, mut config: RunConfig,
on_output: Emit<OutputChunk>, on_output: Emit<OutputChunk>,
agent: Option<&crate::mcp::run::agent::Launch>,
cancel: &ide_core::CancellationToken, cancel: &ide_core::CancellationToken,
) -> IdeResult<RunInfo> { ) -> IdeResult<RunInfo> {
if cancel.is_cancelled() { if cancel.is_cancelled() {
@@ -295,9 +282,6 @@ async fn start_inner(
if let Some(plugins) = host.plugin_host() { if let Some(plugins) = host.plugin_host() {
sink = plugins.run_operation_sink(window, &mut config, sink)?; sink = plugins.run_operation_sink(window, &mut config, sink)?;
} }
if let Some(agent) = agent {
sink = agent.wrap_sink(sink);
}
if config.command.is_empty() { if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command")); 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() { if let Some(plugins) = host.plugin_host() {
plugins.run_operation_stopped(id); plugins.run_operation_stopped(id);
} }
host.mcp_agent_runs.stopping(id);
let settings = match host.project(window) { let settings = match host.project(window) {
Ok(p) => p.settings.effective(), Ok(p) => p.settings.effective(),
Err(_) => host.global_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) 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<()> { pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> {
host.mcp_agent_runs.user_input(id, data);
if host.remote.runs.contains(id) { if host.remote.runs.contains(id) {
return host.remote.runs.write_stdin(id, data); return host.remote.runs.write_stdin(id, data);
} }
+6 -13
View File
@@ -61,16 +61,13 @@ pub async fn sdk_python_project_interpreters(host: &Host, window: &str) -> IdeRe
host.require_trust(window)?; host.require_trust(window)?;
let current = host.project(window).ok().map(|p| p.root.clone()); let current = host.project(window).ok().map(|p| p.root.clone());
let config = host.config_dir.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 || { tokio::task::spawn_blocking(move || {
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()))
.collect();
// Each project resolves its configured interpreter, else its own virtualenv. The // 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 // 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. // once: the recent list can hold ~30 projects, and each probe starts a process.
@@ -524,10 +521,6 @@ mod tests {
host.trust.decide(&root, true, false).unwrap(); host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).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(); let list = sdk_python_project_interpreters(&host, "main").await.unwrap();
assert_eq!(list.len(), 1, "{list:?}"); assert_eq!(list.len(), 1, "{list:?}");
assert_eq!(&*list[0].project, other.as_path()); assert_eq!(&*list[0].project, other.as_path());
+2 -30
View File
@@ -4125,11 +4125,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) { 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())); 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)); items.retain(|d| decoration_style(&d.style));
if items.iter().any(|d| d.range.start > d.range.end) { if items.iter().any(|d| d.range.start > d.range.end) {
return Err(HostError::Invalid("reversed decoration range".into())); return Err(HostError::Invalid("reversed decoration range".into()));
@@ -4148,7 +4143,7 @@ impl PluginApi {
set_id: set.into(), set_id: set.into(),
tree, tree,
items, items,
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)), file: file.filter(|f| decoration_style(&f.style)),
}) })
} else { } else {
None None
@@ -4320,33 +4315,10 @@ mod workbench_tests {
api.file_decorations( api.file_decorations(
Some(&path), Some(&path),
"tree", "tree",
Some(ide_plugin::FileDecoration { Some(ide_plugin::FileDecoration { badge: Some("abc".into()), style: "info".into(), tooltip: None })
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None
})
) )
.is_err() .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); api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty()); assert!(api.shared.decoration_sets().is_empty());
} }
+1 -178
View File
@@ -112,16 +112,6 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
assert_eq!(found["more"], true); assert_eq!(found["more"], true);
let all = h.json("search_text", json!({"q":"fresh","limit":20})).await; let all = h.json("search_text", json!({"q":"fresh","limit":20})).await;
assert_eq!(all["items"][0]["filePath"], "src/a.txt"); 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; 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"))); 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; 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() .iter()
.rev() .rev()
.find_map(|event| match event { .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, .. } => Some(*id),
IdeEvent::WorkspaceEditApplied { id, by_agent, .. } => {
assert!(*by_agent, "an MCP edit is the agent's");
Some(*id)
}
_ => None, _ => None,
}) })
.unwrap(); .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!(!format!("{result:?}").contains("denied"), "{result:?}");
assert!(!h.events.lock().unwrap().iter().any(|e| matches!(e, IdeEvent::McpRequest { .. })), "nothing asked"); 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}");
}
-71
View File
@@ -252,77 +252,6 @@ async fn maven_sync() {
assert!(!junit.children.is_empty(), "{junit:?}"); 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)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Gradle and a JDK"] #[ignore = "project smoke: needs Gradle and a JDK"]
async fn gradle_sync() { async fn gradle_sync() {
+6 -17
View File
@@ -223,8 +223,6 @@ impl ClientHandler {
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) { if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
return; return;
} }
let mut params = params;
crate::virtual_doc::normalize_uris(&mut params);
let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) { let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
@@ -252,14 +250,7 @@ impl ClientHandler {
{ {
return; return;
} }
// A late answer for an older version of the text is shown, but isn't fresh (`check_files`). let shown = self.server.set_diagnostics(path.clone(), params.diagnostics);
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)
};
mgr.emit_diagnostics(&self.server, path, shown, self.encoding()); 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)> { fn apply_edit(&self, mgr: &LspManager, params: Value) -> Result<Value, (i64, String)> {
let edit: lsp::WorkspaceEdit = let edit: lsp::WorkspaceEdit = params
params.get("edit").cloned().ok_or_else(|| (codes::INVALID_PARAMS, "missing edit".to_owned())).and_then( .get("edit")
|mut e| { .cloned()
crate::virtual_doc::normalize_uris(&mut e); .ok_or_else(|| (codes::INVALID_PARAMS, "missing edit".to_owned()))
serde_json::from_value(e).map_err(|e| (codes::INVALID_PARAMS, e.to_string())) .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. // Convert before calling the hook: it re-enters document sync.
let edit = convert::workspace_edit(&mgr.conv(self.encoding()), edit); let edit = convert::workspace_edit(&mgr.conv(self.encoding()), edit);
let applied = match mgr.apply_edit_hook() { let applied = match mgr.apply_edit_hook() {
-119
View File
@@ -22,9 +22,6 @@ use std::time::{Duration, Instant};
/// How long Inspect Code waits for a server that is still starting (jdtls, /// How long Inspect Code waits for a server that is still starting (jdtls,
/// rust-analyzer on a large workspace). /// rust-analyzer on a large workspace).
const INSPECT_START_WAIT: Duration = Duration::from_secs(60); 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)] #[derive(Clone, Copy, Debug)]
pub struct InspectOptions { pub struct InspectOptions {
@@ -51,29 +48,6 @@ pub trait InspectHost: Send + Sync + 'static {
fn close(&self, doc: DocId); 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. /// What became of one file.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum FileInspection { 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( async fn pull_diagnostics(
&self, &self,
t: &Target, t: &Target,
+8 -11
View File
@@ -40,7 +40,7 @@ mod vue_navigation;
mod vue_refs; mod vue_refs;
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES}; 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 jdtls::jvm_inspect::JdtJvmInspector;
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable}; pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
pub use launch::{LaunchError, Launcher, ProcessLauncher}; pub use launch::{LaunchError, Launcher, ProcessLauncher};
@@ -120,8 +120,6 @@ struct DocState {
compose_detected: bool, compose_detected: bool,
/// The notebook a code cell belongs to: synced through `notebookDocument/*`, not as a file. /// The notebook a code cell belongs to: synced through `notebookDocument/*`, not as a file.
notebook: Option<PathBuf>, notebook: Option<PathBuf>,
/// When the servers last got new text (open or change): diagnostics published before are stale.
changed: std::time::Instant,
} }
impl DocState { impl DocState {
@@ -160,8 +158,6 @@ struct DocSnap {
language: LanguageId, language: LanguageId,
text: Arc<DocText>, text: Arc<DocText>,
servers: Vec<Arc<Server>>, servers: Vec<Arc<Server>>,
/// [`DocState::changed`].
changed: std::time::Instant,
} }
impl DocSnap { impl DocSnap {
@@ -1174,7 +1170,6 @@ impl LspManager {
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(), servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
compose_detected, compose_detected,
notebook: None, notebook: None,
changed: std::time::Instant::now(),
}; };
let compose_added = { let compose_added = {
let mut docs = self.docs.lock(); let mut docs = self.docs.lock();
@@ -1222,7 +1217,6 @@ impl LspManager {
let mut docs = self.docs.lock(); let mut docs = self.docs.lock();
let Some(d) = docs.get_mut(&doc) else { return }; let Some(d) = docs.get_mut(&doc) else { return };
d.version = version as i32; d.version = version as i32;
d.changed = std::time::Instant::now();
// Servers that have this document open on their current instance. // Servers that have this document open on their current instance.
let targets: Vec<(Arc<Server>, Running, SyncKind, Encoding)> = d let targets: Vec<(Arc<Server>, Running, SyncKind, Encoding)> = d
@@ -1398,7 +1392,6 @@ impl LspManager {
language: d.language.clone(), language: d.language.clone(),
text: d.text.clone(), text: d.text.clone(),
servers, servers,
changed: d.changed,
}) })
} }
@@ -1440,9 +1433,13 @@ impl LspManager {
let params = serde_json::to_value(params).map_err(|e| IdeError::internal(e.to_string()))?; let params = serde_json::to_value(params).map_err(|e| IdeError::internal(e.to_string()))?;
let stamp = self.injected_request_stamp(&params); let stamp = self.injected_request_stamp(&params);
let fut = async { let fut = async {
let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?; if t.server.name == "csharp-ls" {
virtual_doc::normalize_uris(&mut value); let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?;
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}"))) 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 { let result = match tokio::time::timeout(timeout, fut).await {
Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)), Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)),
-3
View File
@@ -213,7 +213,6 @@ impl LspManager {
{ {
if d.text.as_str() != cell.source { if d.text.as_str() != cell.source {
Arc::make_mut(&mut d.text).set(&cell.source); Arc::make_mut(&mut d.text).set(&cell.source);
d.changed = std::time::Instant::now();
} }
d.version += 1; d.version += 1;
state.cells.push((cell.id.clone(), doc)); 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(), servers: state.servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
compose_detected: false, compose_detected: false,
notebook: Some(path.to_owned()), notebook: Some(path.to_owned()),
changed: std::time::Instant::now(),
}, },
); );
state.cells.push((cell.id.clone(), doc)); state.cells.push((cell.id.clone(), doc));
@@ -261,7 +259,6 @@ impl LspManager {
} }
Arc::make_mut(&mut d.text).set(&cell.source); Arc::make_mut(&mut d.text).set(&cell.source);
d.version += 1; d.version += 1;
d.changed = std::time::Instant::now();
content.push(json!({ content.push(json!({
"document": { "uri": d.uri, "version": d.version }, "document": { "uri": d.uri, "version": d.version },
"changes": [{ "text": cell.source }], "changes": [{ "text": cell.source }],
-32
View File
@@ -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 ---- // ---- transiently opened files ----
/// Registers a holder of the transient file `path` on instance `generation`; /// Registers a holder of the transient file `path` on instance `generation`;
@@ -770,20 +752,6 @@ impl Server {
self.combine(path) 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 /// Records a pulled report for `path` (`None` items: unchanged since `result_id`'s
/// predecessor); returns what the file now shows. /// predecessor); returns what the file now shows.
pub fn set_pulled( pub fn set_pulled(
+10 -58
View File
@@ -18,36 +18,23 @@ const URI_ESCAPES: &AsciiSet = &CONTROLS
.add(b'|') .add(b'|')
.add(b'}'); .add(b'}');
/// Servers can leave spaces and Unicode unescaped in document URIs: csharp-ls uses .NET /// csharp-ls uses .NET Uri.ToString(), which can leave spaces and Unicode unescaped.
/// `Uri.ToString()`, and Java servers build some URIs with `java.net.URI.toString()` (which keeps /// Normalize only document URI fields, before lsp-types' strict URI deserialization.
/// non-ASCII characters), e.g. jdtls in a refactoring's edit for a project under a Chinese path. pub(crate) fn normalize_csharp_uris(value: &mut serde_json::Value) {
/// 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) {
match 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) => { 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. // Opaque payloads must survive resolve/execute round trips unchanged.
if matches!(key.as_str(), "data" | "arguments") { if matches!(key.as_str(), "data" | "arguments") {
continue; continue;
} }
if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri") if matches!(key.as_str(), "uri" | "targetUri")
&& let Some(uri) = value.as_str().and_then(escaped_uri) && let Some(uri) = value.as_str().filter(|s| s.starts_with("csharp:"))
{ {
*value = uri.into(); *value = utf8_percent_encode(uri, URI_ESCAPES).to_string().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();
} else { } 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)] #[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct CSharpMetadata { pub(crate) struct CSharpMetadata {
@@ -225,7 +202,7 @@ mod tests {
{ "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } } { "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } }
]); ]);
let original = response.clone(); let original = response.clone();
normalize_uris(&mut response); normalize_csharp_uris(&mut response);
assert_eq!(response[0]["uri"], encoded); assert_eq!(response[0]["uri"], encoded);
assert_eq!(response[0]["source"], uri); assert_eq!(response[0]["source"], uri);
assert_eq!(response[1]["location"]["targetUri"], encoded); assert_eq!(response[1]["location"]["targetUri"], encoded);
@@ -233,31 +210,6 @@ mod tests {
assert!(encoded.parse::<lsp_types::Uri>().is_ok()); 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] #[test]
fn jdt_titles() { fn jdt_titles() {
assert_eq!(title(JDT, true), "SpringApplication.class"); assert_eq!(title(JDT, true), "SpringApplication.class");
-70
View File
@@ -2178,76 +2178,6 @@ async fn missing_server_reports_once() {
assert!(h.mgr.semantic_tokens(doc).await.unwrap().is_none()); 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")] #[tokio::test(flavor = "multi_thread")]
async fn launch_error_fails_without_starting_the_server() { async fn launch_error_fails_without_starting_the_server() {
let h = harness(&[("mock", incremental_caps())]); let h = harness(&[("mock", incremental_caps())]);
+9 -56
View File
@@ -1,7 +1,6 @@
//! `<app-data>/mcp/server.json`: where the running IDE's server listens and //! `<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 //! 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 server stops.
//! the user chose a fixed token.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::io; use std::io;
@@ -15,28 +14,6 @@ pub fn discovery_path(app_data: &Path) -> PathBuf {
app_data.join(DISCOVERY_FILE) 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discovery { pub struct Discovery {
pub port: u16, pub port: u16,
@@ -55,8 +32,15 @@ impl Discovery {
/// Writes the file atomically, readable only by the user. /// Writes the file atomically, readable only by the user.
pub fn write(&self, path: &Path) -> io::Result<()> { pub fn write(&self, path: &Path) -> io::Result<()> {
let dir = path.parent().ok_or_else(|| io::Error::other("no parent directory"))?;
std::fs::create_dir_all(dir)?;
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
let bytes = serde_json::to_vec_pretty(self).map_err(io::Error::other)?; let 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. /// 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)] #[cfg(unix)]
fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write; use std::io::Write;
@@ -119,23 +91,4 @@ mod tests {
Discovery::remove_if_ours(&path); Discovery::remove_if_ours(&path);
assert!(!path.exists()); 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");
}
} }
+2 -2
View File
@@ -22,8 +22,8 @@ mod protocol;
mod server; mod server;
pub mod stdio; pub mod stdio;
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path}; pub use discovery::{DISCOVERY_FILE, Discovery, discovery_path};
pub use server::{McpServer, ServerOptions}; pub use server::McpServer;
pub use tokio_util::sync::CancellationToken; pub use tokio_util::sync::CancellationToken;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
+25 -292
View File
@@ -3,39 +3,17 @@
use crate::discovery::Discovery; use crate::discovery::Discovery;
use crate::{CancellationToken, PROJECT_HEADER, ToolRegistry, protocol}; use crate::{CancellationToken, PROJECT_HEADER, ToolRegistry, protocol};
use bytes::Bytes; use bytes::Bytes;
use http_body_util::{BodyExt, Either, Full, Limited}; use http_body_util::{BodyExt, Full, Limited};
use hyper::body::{Frame, Incoming}; use hyper::body::Incoming;
use hyper::header::{self, HeaderMap, HeaderValue}; use hyper::header::{self, HeaderMap, HeaderValue};
use hyper::{Method, Request, Response, StatusCode}; use hyper::{Method, Request, Response, StatusCode};
use serde_json::{Value, json};
use std::convert::Infallible; use std::convert::Infallible;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
use tokio::sync::watch; use tokio::sync::watch;
use tokio::task::{JoinError, JoinHandle};
use tokio_util::sync::DropGuard;
/// Largest request body. /// Largest request body.
const MAX_BODY: usize = 8 << 20; 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 { struct Shared {
registry: Arc<dyn ToolRegistry>, registry: Arc<dyn ToolRegistry>,
@@ -49,25 +27,12 @@ struct Shared {
/// removes its discovery file. /// removes its discovery file.
pub struct McpServer { pub struct McpServer {
port: u16, port: u16,
/// Why the port asked for in [`ServerOptions::port`] was not used.
port_unavailable: Option<String>,
token: String, token: String,
discovery: Option<PathBuf>, discovery: Option<PathBuf>,
shutdown: watch::Sender<bool>, shutdown: watch::Sender<bool>,
cancel: CancellationToken, 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 { impl McpServer {
/// Starts listening on `127.0.0.1:<random port>` with a new token, on /// Starts listening on `127.0.0.1:<random port>` with a new token, on
/// `runtime`, and writes the discovery file at `discovery` if given. /// `runtime`, and writes the discovery file at `discovery` if given.
@@ -76,32 +41,10 @@ impl McpServer {
discovery: Option<PathBuf>, discovery: Option<PathBuf>,
runtime: &tokio::runtime::Handle, runtime: &tokio::runtime::Handle,
) -> std::io::Result<Self> { ) -> std::io::Result<Self> {
Self::start_with(registry, discovery, runtime, ServerOptions::default()) let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
}
/// [`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),
};
listener.set_nonblocking(true)?; listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port(); let port = listener.local_addr()?.port();
let token = match options.token { let token = new_token()?;
Some(token) => token,
None => new_token()?,
};
let listener = { let listener = {
let _entered = runtime.enter(); let _entered = runtime.enter();
tokio::net::TcpListener::from_std(listener)? tokio::net::TcpListener::from_std(listener)?
@@ -126,18 +69,13 @@ impl McpServer {
}); });
runtime.spawn(accept(listener, shared, stopped)); runtime.spawn(accept(listener, shared, stopped));
tracing::info!("MCP server on 127.0.0.1:{port}"); 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 { pub fn port(&self) -> u16 {
self.port 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. /// The bearer token of this run.
pub fn token(&self) -> &str { pub fn token(&self) -> &str {
&self.token &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) 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 { async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) -> Reply {
if req.uri().path() != "/mcp" { if req.uri().path() != "/mcp" {
@@ -221,7 +163,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
return response; return response;
} }
if req.method() != Method::POST { 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"); let mut response = plain(StatusCode::METHOD_NOT_ALLOWED, "only POST");
response.headers_mut().insert(header::ALLOW, HeaderValue::from_static("POST")); response.headers_mut().insert(header::ALLOW, HeaderValue::from_static("POST"));
return response; 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 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 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 { let body = match Limited::new(req.into_body(), MAX_BODY).collect().await {
Ok(body) => body.to_bytes(), Ok(body) => body.to_bytes(),
Err(e) => return plain(StatusCode::PAYLOAD_TOO_LARGE, &format!("request body: {e}")), Err(e) => return plain(StatusCode::PAYLOAD_TOO_LARGE, &format!("request body: {e}")),
}; };
let message = serde_json::from_slice::<Value>(&body).ok(); let initializing = serde_json::from_slice::<serde_json::Value>(&body)
let initializing = message.as_ref().is_some_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize")); .is_ok_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 response_scope = scope.clone(); let response_scope = scope.clone();
let registry = shared.registry.clone(); let registry = shared.registry.clone();
let shared = shared.clone(); let shared = shared.clone();
let cancel = shared.cancel.child_token(); 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 guard = cancel.clone().drop_guard(); let handled = tokio::task::spawn_blocking(move || {
let mut handled = tokio::task::spawn_blocking(move || {
protocol::handle_body(registry.as_ref(), project.as_deref(), &body, &shared.requests, &scope, cancel) protocol::handle_body(registry.as_ref(), project.as_deref(), &body, &shared.requests, &scope, cancel)
}); })
if streams && !initializing { .await;
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 {
match handled { match handled {
Ok(Ok(Some(reply))) => { Ok(Ok(Some(reply))) => {
let mut response = json(StatusCode::OK, &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.headers_mut().insert("mcp-session-id", value);
} }
response 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 /// 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. /// page (DNS rebinding) can't reach the tools even before the token check.
fn local_request(headers: &HeaderMap, port: u16) -> bool { 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 { fn plain(status: StatusCode, text: &str) -> Response<Full<Bytes>> {
let mut response = Response::new(Either::Left(Full::new(Bytes::from(text.to_owned())))); let mut response = Response::new(Full::new(Bytes::from(text.to_owned())));
*response.status_mut() = status; *response.status_mut() = status;
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8")); response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"));
response response
} }
fn json(status: StatusCode, value: &Value) -> Reply { fn json(status: StatusCode, value: &serde_json::Value) -> Response<Full<Bytes>> {
let mut response = Response::new(Either::Left(Full::new(Bytes::from(value.to_string())))); let mut response = Response::new(Full::new(Bytes::from(value.to_string())));
*response.status_mut() = status; *response.status_mut() = status;
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json")); response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
response response
} }
fn empty(status: StatusCode) -> Reply { fn empty(status: StatusCode) -> Response<Full<Bytes>> {
let mut response = Response::new(Either::Left(Full::new(Bytes::new()))); let mut response = Response::new(Full::new(Bytes::new()));
*response.status_mut() = status; *response.status_mut() = status;
response response
} }
@@ -404,134 +265,6 @@ fn empty(status: StatusCode) -> Reply {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn only_loopback_hosts_and_origins_of_this_server_pass() { fn only_loopback_hosts_and_origins_of_this_server_pass() {
+14 -106
View File
@@ -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 /// 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 /// writes its answer, if any, as one line to `output`.
/// answer, and before it the progress notifications of a long call.
pub async fn bridge<R, W>(discovery: &Path, project: &Path, input: R, mut output: W) -> std::io::Result<()> pub async fn bridge<R, W>(discovery: &Path, project: &Path, input: R, mut output: W) -> std::io::Result<()>
where where
R: AsyncBufRead + Unpin, R: AsyncBufRead + Unpin,
@@ -45,37 +44,23 @@ where
let scope = crate::server::new_token()?; let scope = crate::server::new_token()?;
let mut lines = input.lines(); let mut lines = input.lines();
let mut pending = tokio::task::JoinSet::new(); let mut pending = tokio::task::JoinSet::new();
let (sent, mut received) = tokio::sync::mpsc::unbounded_channel::<Value>();
let mut eof = false; let mut eof = false;
loop { loop {
if eof && pending.is_empty() && received.is_empty() {
break;
}
let reply = tokio::select! { let reply = tokio::select! {
line = lines.next_line(), if !eof => { line = lines.next_line(), if !eof => {
let Some(line) = line? else { eof = true; continue }; let Some(line) = line? else { eof = true; continue };
if line.trim().is_empty() { continue; } if line.trim().is_empty() { continue; }
match serde_json::from_str::<Value>(&line) { match serde_json::from_str::<Value>(&line) {
Ok(message) => { Ok(message) => {
let (discovery, project, scope, sent) = let (discovery, project, scope) = (discovery.to_owned(), project.to_owned(), scope.clone());
(discovery.to_owned(), project.to_owned(), scope.clone(), sent.clone()); pending.spawn(async move { relay(&discovery, &project, &message, &scope).await });
pending.spawn(async move {
if let Some(reply) = relay(&discovery, &project, &message, &scope, &sent).await {
let _ = sent.send(reply);
}
});
continue; continue;
} }
Err(e) => Some(error_response(Value::Null, -32700, &format!("not JSON: {e}"))), Err(e) => Some(error_response(Value::Null, -32700, &format!("not JSON: {e}"))),
} }
} }
Some(message) = received.recv() => Some(message), reply = pending.join_next(), if !pending.is_empty() => match reply { Some(reply) => reply.map_err(std::io::Error::other)?, None => None },
done = pending.join_next(), if !pending.is_empty() => { else => break,
if let Some(Err(e)) = done {
return Err(std::io::Error::other(e));
}
continue;
}
}; };
if let Some(reply) = reply { if let Some(reply) = reply {
output.write_all(reply.to_string().as_bytes()).await?; 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 /// 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` /// it when the IDE can't be reached.
/// with the messages before it. async fn relay(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Option<Value> {
async fn relay( let failure = match post(discovery, project, message, scope).await {
discovery: &Path, Ok((StatusCode::ACCEPTED, _)) => return None,
project: &Path, Ok((_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) {
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) {
Ok(reply) => return Some(reply), Ok(reply) => return Some(reply),
Err(_) => String::from_utf8_lossy(&body).trim().to_owned(), 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, Err(e) => e,
}; };
let errors: Vec<Value> = requests(message).into_iter().map(|id| error_response(id, NOT_OPEN, &failure)).collect(); 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 /// POSTs `message` to the server in the discovery file; a message for the
/// user when that fails. /// user when that fails.
async fn post( async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Result<(StatusCode, Bytes), String> {
discovery: &Path,
project: &Path,
message: &Value,
scope: &str,
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
) -> Result<Answer, String> {
let not_running = || { let not_running = || {
format!( format!(
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Agents › MCP Server", "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())?; .map_err(|e| e.to_string())?;
let response = sender.send_request(request).await.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?; let response = sender.send_request(request).await.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
let status = response.status(); let status = response.status();
let streamed = response let body = response.into_body().collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
.headers() Ok((status, body))
.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());
}
} }
+1 -46
View File
@@ -1,7 +1,7 @@
//! The server over real HTTP, and the stdio bridge in front of it, with a //! The server over real HTTP, and the stdio bridge in front of it, with a
//! registry standing in for the IDE host. //! registry standing in for the IDE host.
use ide_mcp::{Discovery, McpServer, ServerOptions, Tool, ToolCallError, ToolRegistry, discovery_path, stdio}; use ide_mcp::{Discovery, McpServer, Tool, ToolCallError, ToolRegistry, discovery_path, stdio};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -39,10 +39,6 @@ impl ToolRegistry for Registry {
match name { match name {
"echo" if args["text"] == "fail" => Err(ToolCallError::Failed("it failed".into())), "echo" if args["text"] == "fail" => Err(ToolCallError::Failed("it failed".into())),
"echo" => Ok(format!("{} in {}", args["text"].as_str().unwrap_or(""), project.display())), "echo" => Ok(format!("{} in {}", args["text"].as_str().unwrap_or(""), project.display())),
"sleep" => {
std::thread::sleep(std::time::Duration::from_millis(args["ms"].as_u64().unwrap()));
Ok("slept".into())
}
_ => Err(ToolCallError::UnknownTool(format!("no tool {name:?}"))), _ => Err(ToolCallError::UnknownTool(format!("no tool {name:?}"))),
} }
} }
@@ -256,20 +252,6 @@ async fn the_stdio_bridge_relays_lines_and_explains_a_missing_ide() {
assert!(reply["error"]["message"].as_str().unwrap().contains("MCP server is not running"), "{reply}"); assert!(reply["error"]["message"].as_str().unwrap().contains("MCP server is not running"), "{reply}");
} }
#[tokio::test(flavor = "multi_thread")]
async fn the_stdio_bridge_relays_a_long_call_answered_on_an_event_stream() {
let s = setup();
let call = json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": { "name": "sleep", "arguments": { "ms": 1500 }, "_meta": { "progressToken": 4 } } });
let mut output = Vec::new();
stdio::bridge(&s.discovery, &s.project, format!("{call}\n").as_bytes(), &mut output).await.unwrap();
let replies: Vec<Value> =
String::from_utf8(output).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let answer = replies.last().unwrap();
assert_eq!(answer["id"], 4, "{replies:?}");
assert_eq!(answer["result"]["content"][0]["text"], "slept");
}
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn routing_an_argument_overrides_the_header_and_ambiguity_lists_projects() { async fn routing_an_argument_overrides_the_header_and_ambiguity_lists_projects() {
struct Many(Vec<PathBuf>); struct Many(Vec<PathBuf>);
@@ -366,30 +348,3 @@ async fn a_cancellation_that_arrives_before_the_call_is_remembered() {
let (_, body) = post(&s, "/mcp", &headers, &call).await; let (_, body) = post(&s, "/mcp", &headers, &call).await;
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["isError"], true); assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["isError"], true);
} }
#[tokio::test(flavor = "multi_thread")]
async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_one() {
let mut s = setup();
s.server.take().unwrap().stop();
let free = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let token = "ab".repeat(32);
let options = ServerOptions { port: Some(free), token: Some(token.clone()) };
let registry = Arc::new(Registry { project: s.project.clone() });
let runtime = tokio::runtime::Handle::current();
s.server =
Some(McpServer::start_with(registry.clone(), Some(s.discovery.clone()), &runtime, options.clone()).unwrap());
assert_eq!((s.server().port(), s.server().token()), (free, token.as_str()));
assert!(s.server().port_unavailable().is_none());
assert_eq!(Discovery::read(&s.discovery).unwrap().port, free);
let list = rpc(&s, "tools/list", json!({})).await;
assert_eq!(list["result"]["tools"][0]["name"], "echo");
// The port is taken (by the first server): a second one listens elsewhere and says why.
let second = McpServer::start_with(registry, None, &runtime, options).unwrap();
assert_ne!(second.port(), free);
assert!(
second.port_unavailable().is_some_and(|why| why.contains(&free.to_string())),
"{:?}",
second.port_unavailable()
);
}
-3
View File
@@ -922,9 +922,6 @@ pub struct FileDecoration {
pub badge: Option<String>, pub badge: Option<String>,
pub style: String, pub style: String,
pub tooltip: Option<String>, pub tooltip: Option<String>,
/// Text shown after the name (API 1.7 `set-file-note`).
#[serde(default)]
pub note: Option<String>,
} }
/// Commit metadata, date in Unix seconds. /// Commit metadata, date in Unix seconds.
#[derive(Clone, Debug, Serialize, Deserialize, Type)] #[derive(Clone, Debug, Serialize, Deserialize, Type)]
+1 -12
View File
@@ -2497,7 +2497,7 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
.file_decorations( .file_decorations(
Some(&real), Some(&real),
&set_id, &set_id,
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }), Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip }),
) )
.map_err(to_wit)) .map_err(to_wit))
} }
@@ -2507,17 +2507,6 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
} }
Ok(self.host.file_decorations(None, &set_id, None).map_err(to_wit)) Ok(self.host.file_decorations(None, &set_id, None).map_err(to_wit))
} }
fn set_file_note(&mut self, path: String, set_id: String, note: String, tooltip: Option<String>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let real = match self.decoration_path(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
} }
pub(crate) mod language_features_bindings { pub(crate) mod language_features_bindings {
@@ -1 +1 @@
d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee 3c7f35facd15ee8380ccb6475b7c051d500e38346b711e1805077becd0c75f75
@@ -1 +1 @@
51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f 3879822d975695c6258e95286b0e64c5ee1e3265876501efaf0e577b38f8222b
@@ -1 +1 @@
40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d 228ae888bf556ce5cedc7843eb20e92f221d9e1f640f3c6c595bed3fd6be6051
@@ -1 +1 @@
08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367 41de239b6f723020b38eb2b67f642b7c88cc88c79117c3072ba122633afc418c
@@ -1 +1 @@
43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a 78c5ed3c756498c7619b46c3622ae7a59461c61c747b45caecd79b341ee654de
@@ -1 +1 @@
520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34 ee848321e53cb181536e6158570dbb4ab02a6fd2a1a064b068803ac7da7f3448
@@ -1 +1 @@
acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac b587de945875a98f1d4ca20fab7cf94fb4dc6d848136aa19ab0e0e5319155a80
@@ -1 +1 @@
0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3 1c8776dcae35d2d49dd6c1e32b0cdd354de1eb0f72a473245a2c47368b89ec30
@@ -1 +1 @@
17f837c8d6f58800728e00060afe68b2a093085d816d809c9e67065fdd165352 2a517cbffd6d3cdda37117d8ca8815978a09995923fc3d5aa5af7020afdac56a
@@ -1 +1 @@
2cca80220fab6764a8e1f51db8ed2f55b603000c8d0dbf5913a441876b7857ba dbd912b1f8488ecd446af07b5dcaef55d44bce57c1a55be5c0b0fb33e2697903
@@ -1 +1 @@
54211437002ad83a9d3d642f2dca5f37cadd89d7d77ddc7de545db87b99fb8b7 efdc84cd2bedb3989b03d19b569d09958f26e502db65db4dd786eb3438a7c407
@@ -1 +1 @@
4326d9cdefe71f4bb9aab043ccef883abbd13145b1579352b97c66889f514c68 be6bc15ee42a5e21781ba54f548007351a46b669882ee4491da20ff556927a06
@@ -1 +1 @@
2469023f4f29b9abdaa27c9d96774229c4dd002366421dc508926c3fed1fba1b e5c3a3beda750dff27d7b823676d53f15b7c76b8ad539594cd913bd178d750da
@@ -1 +1 @@
233461ab52a12e27fa600e108e83cab1b930b3e61812e0c048c99c1e18c6d646 322de3299f0413101c02358836e7df7e06e58cbb22c11897664564abbb8af090
@@ -1 +1 @@
bfc654523da608bbc008ea6a15a0cfd0b129ffab70088bf6b610786b63b5352e d768ff6a4a466cc6f1d72499df3ccb4ae76872436b8eddff27f62aa156331a37
@@ -1 +1 @@
c8536be072d27aa89f5e92b5b96e38ed65b7f06c35c05ecbd4a151218203c5f7 57aea107b2c10e4d30fa3785ac4c6566f1b40908a18a3859aca8be047fac9518
@@ -1 +1 @@
7c0c1f5035e12c794d48cb75a8fc259d11dd99ba3188030fe1d9956720ee6ad5 c98bcc89750e26e738d7115a7c0454fde25367a0a23bdc2ff95fb5a5dacc9f98
@@ -1 +1 @@
3cf5fd6ab0ce909b806230e20a7fa46264f85937018cfa68865ec0eeb92d4b72 a4c23b93a14eda705cb18c88bbc2373d1ef3f96031ff2df21b8b092b50633996
@@ -1 +1 @@
d349977fd3cbfba92d1d953ac09f65dae5b83b7070723b59553d791c638acf81 aec002b3dd310068e69a6ae67a49d30655bf8baea9153a110a407e9d408e6880
@@ -1 +1 @@
e796039ae2f8e534ffc8c2ed2e47e910aaf593c464525287699cf1daf3e195ef 08ece563c3b55c2b86e68fbb0815ddcbb63beb6f1f6c32b8dbdbee558a4a66c1
@@ -1 +1 @@
5e084fa409bb0641409110d91f203a6f99b451b95cd8067bd1ee68140bf8a892 450845edf234a1cc78cd6cfc0b812a89f3a2e41484689cc1e1b05be66873d4eb
@@ -1 +1 @@
521557c1a062a9b82afa14547bc54b57255cdbed3b2ab4b1b86424dfb8accbdc 61b735de5ef61dcd0ea8e640c2ad004e84a63fe4dfd7872ffb5e04d6d81dc9a9
@@ -1 +1 @@
5280990269e944aef81ec91b356fadf4aa2fc7e7bb10e4b6842e1dc1c669704a 36776367782eaa4b8b8121f4a665dd8e68525a4836bff1c92bc16770cce14929
@@ -1 +1 @@
ff1391d3c9338b6270fef8af7d7c4a5bc595c391997c220a3a827b4b8a068de8 f6311d232a99356b02a398b2ce3db0865c425f435a8d201157719178ba2b97c7
@@ -1 +1 @@
b78ac0b6fa14401a6379f3e6d07d1398a63d70e882930b3747ec843c0fdb5de7 fe1d26665e3c816ed43fc720acabad03a37b7924bccd4dd8c357fd156947b3c7
@@ -1 +1 @@
55331477c1d5322b0dc35d13fc7f8223bf697f5f21328d340f75519dd1098727 76bab8e48235f9f066f5b9bbfdd9398916b6c3ed73c072c543d80595130519f7
@@ -1 +1 @@
0ae6b7edd0d7cd5025ead4a4afacf0fbe3ebc9548d847f94be0e63585a19747e f9ed2eac306229f76b05d0c4a4fb2f0ed8d81a1dfc5eb50d044bbe10c9206a5f
@@ -1 +1 @@
5d909d6ab2e73fabba2d213a944546ef7e425552852cf7f74e00eb94c9d167ac f2d83d9f56da072d38a1552195af8a9d4edc11888c645627d5dd1e4a46afc4df
+13 -50
View File
@@ -498,8 +498,7 @@ impl ProcessManager {
None, None,
)?; )?;
} }
let decoder = || OutputDecoder::for_pipes(options.encoding.as_deref()); spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?;
spawn_pump(rx, on_output.clone(), entry.clone(), sink, [decoder(), decoder()], notice)?;
Ok(()) Ok(())
})(); })();
if let Err(e) = result { if let Err(e) = result {
@@ -630,10 +629,7 @@ impl ProcessManager {
#[cfg(unix)] #[cfg(unix)]
Some(Box::new(wait_readable)), Some(Box::new(wait_readable)),
) )
.and_then(|_| { .and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None));
let decoder = || OutputDecoder::for_label(options.encoding.as_deref());
spawn_pump(rx, on_output, entry.clone(), sink, [decoder(), decoder()], None)
});
if let Err(e) = result { if let Err(e) = result {
let _ = child.kill(); let _ = child.kill();
let _ = child.wait(); let _ = child.wait();
@@ -1124,35 +1120,18 @@ fn chunk(stream: Stream, text: String) -> OutputChunk {
} }
} }
/// Appends what `decode` writes to `pending`, through the notice filter when there is one.
fn emit(
decode: impl FnOnce(&mut String),
notice: &mut Option<ide_core::output_encoding::NoticeFilter>,
decoded: &mut String,
pending: &mut String,
) {
match notice {
Some(filter) => {
decode(decoded);
filter.filter(decoded, pending);
decoded.clear();
}
None => decode(pending),
}
}
/// Merges both streams in arrival order, batching consecutive output of the same stream. /// Merges both streams in arrival order, batching consecutive output of the same stream.
/// `decoders` decode stdout and stderr.
fn spawn_pump( fn spawn_pump(
rx: mpsc::Receiver<Msg>, rx: mpsc::Receiver<Msg>,
on_output: OutputFn, on_output: OutputFn,
entry: Arc<Entry>, entry: Arc<Entry>,
sink: SharedSink, sink: SharedSink,
decoders: [OutputDecoder; 2], encoding: Option<String>,
notice: Option<String>, notice: Option<String>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
std::thread::Builder::new().name(format!("openidea-run-pump-{}", entry.id.0)).spawn(move || { std::thread::Builder::new().name(format!("openidea-run-pump-{}", entry.id.0)).spawn(move || {
let [mut out_dec, mut err_dec] = decoders; let mut out_dec = OutputDecoder::for_label(encoding.as_deref());
let mut err_dec = OutputDecoder::for_label(encoding.as_deref());
// "Picked up JAVA_TOOL_OPTIONS: …" from every JVM given the console encoding, on // "Picked up JAVA_TOOL_OPTIONS: …" from every JVM given the console encoding, on
// either stream (`spring-boot:run` copies its fork's stderr to Maven's stdout). // either stream (`spring-boot:run` copies its fork's stderr to Maven's stdout).
let filter = || notice.clone().map(ide_core::output_encoding::NoticeFilter::new); let filter = || notice.clone().map(ide_core::output_encoding::NoticeFilter::new);
@@ -1161,8 +1140,6 @@ fn spawn_pump(
let mut pending = String::new(); let mut pending = String::new();
let mut pending_stream = Stream::Stdout; let mut pending_stream = Stream::Stdout;
let mut batch_started: Option<Instant> = None; let mut batch_started: Option<Instant> = None;
// Since when a decoder holds part of a line back to tell its encoding.
let mut held_since: Option<Instant> = None;
let mut status: Option<Exit> = None; let mut status: Option<Exit> = None;
let mut drain_deadline: Option<Instant> = None; let mut drain_deadline: Option<Instant> = None;
@@ -1177,7 +1154,6 @@ fn spawn_pump(
let now = Instant::now(); let now = Instant::now();
let wait = [ let wait = [
batch_started.map(|t| (t + BATCH_INTERVAL).saturating_duration_since(now)), batch_started.map(|t| (t + BATCH_INTERVAL).saturating_duration_since(now)),
held_since.map(|t| (t + BATCH_INTERVAL).saturating_duration_since(now)),
drain_deadline.map(|t| t.saturating_duration_since(now)), drain_deadline.map(|t| t.saturating_duration_since(now)),
] ]
.into_iter() .into_iter()
@@ -1194,8 +1170,14 @@ fn spawn_pump(
Stream::Stdout => (&mut out_dec, &mut out_notice), Stream::Stdout => (&mut out_dec, &mut out_notice),
Stream::Stderr => (&mut err_dec, &mut err_notice), Stream::Stderr => (&mut err_dec, &mut err_notice),
}; };
emit(|out| dec.decode_into(&bytes, out), notice, &mut decoded, &mut pending); match notice {
held_since = if out_dec.holds() || err_dec.holds() { held_since.or(Some(now)) } else { None }; Some(filter) => {
dec.decode_into(&bytes, &mut decoded);
filter.filter(&decoded, &mut pending);
decoded.clear();
}
None => dec.decode_into(&bytes, &mut pending),
}
if !pending.is_empty() && batch_started.is_none() { if !pending.is_empty() && batch_started.is_none() {
batch_started = Some(Instant::now()); batch_started = Some(Instant::now());
} }
@@ -1211,25 +1193,6 @@ fn spawn_pump(
if drain_deadline.is_some_and(|d| Instant::now() >= d) { if drain_deadline.is_some_and(|d| Instant::now() >= d) {
break; break;
} }
// Nothing more came: show a held prompt.
if held_since.is_some_and(|t| t.elapsed() >= BATCH_INTERVAL) {
held_since = None;
for (stream, dec, notice) in [
(Stream::Stdout, &mut out_dec, &mut out_notice),
(Stream::Stderr, &mut err_dec, &mut err_notice),
] {
if dec.holds() {
if stream != pending_stream {
flush(&mut pending, pending_stream, &mut batch_started);
pending_stream = stream;
}
emit(|out| dec.release(out), notice, &mut decoded, &mut pending);
}
}
if !pending.is_empty() && batch_started.is_none() {
batch_started = Some(Instant::now());
}
}
if batch_started.is_some_and(|t| t.elapsed() >= BATCH_INTERVAL) { if batch_started.is_some_and(|t| t.elapsed() >= BATCH_INTERVAL) {
flush(&mut pending, pending_stream, &mut batch_started); flush(&mut pending, pending_stream, &mut batch_started);
} }
+1 -390
View File
@@ -136,7 +136,6 @@ mod tests {
pub enum OutputDecoder { pub enum OutputDecoder {
Utf8(Utf8Decoder), Utf8(Utf8Decoder),
Other(Box<encoding_rs::Decoder>), Other(Box<encoding_rs::Decoder>),
Mixed(Box<MixedDecoder>),
} }
impl OutputDecoder { impl OutputDecoder {
@@ -148,36 +147,10 @@ impl OutputDecoder {
} }
} }
/// [`Self::for_label`] for output read from pipes. On Windows a UTF-8 console also decodes
/// what Windows' own programs write: the lines that are not UTF-8 in the system's console
/// code page (`ping`, `dir`, `cmd`'s messages), whatever the system's language, and UTF-16
/// (`wsl.exe`, `cmd /u`).
pub fn for_pipes(label: Option<&str>) -> Self {
match Self::for_label(label) {
Self::Utf8(_) if cfg!(windows) => Self::Mixed(Box::new(MixedDecoder::new(
ide_core::output_encoding::console_code_page().and_then(NativeDecoder::for_code_page),
))),
other => other,
}
}
pub fn decode_into(&mut self, bytes: &[u8], out: &mut String) { pub fn decode_into(&mut self, bytes: &[u8], out: &mut String) {
match self { match self {
Self::Utf8(d) => d.decode_into(bytes, out), Self::Utf8(d) => d.decode_into(bytes, out),
Self::Other(d) => decode_with(d, bytes, out, false), Self::Other(d) => decode_with(d, bytes, out, false),
Self::Mixed(d) => d.decode_into(bytes, out),
}
}
/// Whether part of a line is held back until it shows its encoding.
pub fn holds(&self) -> bool {
matches!(self, Self::Mixed(d) if d.holds())
}
/// Decodes the held part of a line now (no more output came for a while).
pub fn release(&mut self, out: &mut String) {
if let Self::Mixed(d) = self {
d.release(out);
} }
} }
@@ -189,218 +162,10 @@ impl OutputDecoder {
decode_with(d, &[], &mut out, true); decode_with(d, &[], &mut out, true);
out out
} }
Self::Mixed(d) => d.finish(),
} }
} }
} }
/// Decoder for a Windows code page: `encoding_rs`'s, or Windows' own for the single-byte
/// pages `encoding_rs` lacks (the OEM pages 437, 850, 852…).
pub enum NativeDecoder {
Encoding(Box<encoding_rs::Decoder>),
#[cfg(windows)]
SingleByte(u32),
}
impl NativeDecoder {
/// `None` for UTF-8 and for code pages neither can decode piece by piece.
pub fn for_code_page(cp: u32) -> Option<Self> {
if let Some(enc) = ide_core::output_encoding::encoding_for_code_page(cp) {
return (enc != encoding_rs::UTF_8)
.then(|| Self::Encoding(Box::new(enc.new_decoder_without_bom_handling())));
}
#[cfg(windows)]
if ide_core::output_encoding::is_single_byte_code_page(cp) {
return Some(Self::SingleByte(cp));
}
None
}
fn decode_into(&mut self, bytes: &[u8], out: &mut String, last: bool) {
match self {
Self::Encoding(d) => decode_with(d, bytes, out, last),
#[cfg(windows)]
Self::SingleByte(cp) => out.push_str(&ide_core::output_encoding::decode_code_page(bytes, *cp)),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Line {
/// Only ASCII so far, which reads the same in both.
Open,
Utf8,
Native,
}
/// UTF-8, the system code page for the lines that are not UTF-8, and UTF-16: on pipes, cargo,
/// git or node write UTF-8, Windows' own programs the console code page (GBK, Shift_JIS,
/// EUC-KR, 866…) and a few of them (`wsl.exe`, `cmd /u`) UTF-16LE, even within one command.
///
/// Each line is decoded as UTF-8 or the code page (UTF-8 with replacement characters without
/// one). The part of a line after its first non-ASCII byte is held until the line ends or
/// turns out not to be UTF-8; [`Self::release`] decodes a held prompt when no more output comes.
///
/// A read with NUL bytes, which text in the others never has, is UTF-16LE (ASCII characters
/// and line ends in it have them); UTF-16 goes on until one of its lines has ended and a read
/// without NUL bytes comes.
pub struct MixedDecoder {
utf8: Utf8Decoder,
native: Option<NativeDecoder>,
line: Line,
held: Vec<u8>,
utf16: Option<Utf16>,
}
impl MixedDecoder {
pub fn new(native: Option<NativeDecoder>) -> Self {
Self { utf8: Utf8Decoder::new(), native, line: Line::Open, held: Vec::new(), utf16: None }
}
pub fn decode_into(&mut self, bytes: &[u8], out: &mut String) {
if bytes.is_empty() {
return;
}
let nul = bytes.contains(&0);
match self.utf16.as_ref().map(|u| u.open || nul) {
Some(true) => {
if let Some(u) = &mut self.utf16 {
u.decode(bytes, out);
}
return;
}
Some(false) => self.utf16 = None,
None if nul => {
self.end_line(out);
let mut u = Utf16::new();
u.decode(utf16_aligned(bytes), out);
self.utf16 = Some(u);
return;
}
None => {}
}
for piece in bytes.split_inclusive(|b| *b == b'\n') {
let ends = piece.last() == Some(&b'\n');
match self.line {
Line::Utf8 => self.utf8.decode_into(piece, out),
Line::Native => self.decode_native(piece, out),
Line::Open => {
let mut rest = piece;
if self.held.is_empty() {
let ascii = piece.iter().take_while(|b| b.is_ascii()).count();
out.push_str(std::str::from_utf8(&piece[..ascii]).unwrap_or_default());
rest = &piece[ascii..];
}
self.held.extend_from_slice(rest);
let decided = match std::str::from_utf8(&self.held) {
Err(e) if e.error_len().is_some() => Some(Line::Native),
Ok(_) if ends => Some(Line::Utf8),
_ => None,
};
if let Some(line) = decided {
self.line = line;
self.flush_held(out);
}
}
}
if ends {
self.line = Line::Open;
}
}
}
fn flush_held(&mut self, out: &mut String) {
let held = std::mem::take(&mut self.held);
match self.line {
Line::Native => self.decode_native(&held, out),
Line::Open | Line::Utf8 => self.utf8.decode_into(&held, out),
}
}
fn decode_native(&mut self, bytes: &[u8], out: &mut String) {
match &mut self.native {
Some(native) => native.decode_into(bytes, out, false),
None => self.utf8.decode_into(bytes, out),
}
}
/// Decodes the rest of the current line as it is.
fn end_line(&mut self, out: &mut String) {
if self.holds() {
self.line = if std::str::from_utf8(&self.held).is_ok() { Line::Utf8 } else { Line::Native };
self.flush_held(out);
}
out.push_str(&self.utf8.finish());
self.line = Line::Open;
}
pub fn holds(&self) -> bool {
!self.held.is_empty()
}
/// Decodes the held part of the line as UTF-8: held text has been UTF-8 so far.
pub fn release(&mut self, out: &mut String) {
if self.holds() {
self.line = Line::Utf8;
self.flush_held(out);
}
}
pub fn finish(&mut self) -> String {
let mut out = String::new();
if let Some(mut u) = self.utf16.take() {
decode_with(&mut u.decoder, &[], &mut out, true);
}
self.end_line(&mut out);
if let Some(native) = &mut self.native {
native.decode_into(&[], &mut out, true);
}
out
}
}
struct Utf16 {
decoder: encoding_rs::Decoder,
/// Inside a line or a code unit: the next read goes on in UTF-16 whatever it holds.
open: bool,
/// An odd number of bytes so far.
odd: bool,
}
impl Utf16 {
fn new() -> Self {
Self { decoder: encoding_rs::UTF_16LE.new_decoder_with_bom_removal(), open: false, odd: false }
}
fn decode(&mut self, bytes: &[u8], out: &mut String) {
let start = out.len();
decode_with(&mut self.decoder, bytes, out, false);
self.odd ^= bytes.len() % 2 == 1;
if self.odd {
self.open = true;
} else if out.len() > start {
self.open = !out.ends_with('\n');
}
}
}
/// `bytes` from the first byte of a UTF-16LE code unit. NUL bytes are mostly the high bytes of
/// ASCII characters, at odd offsets from it; at even ones, the first byte is the high byte of a
/// character whose low byte came before (and was shown as ASCII).
fn utf16_aligned(bytes: &[u8]) -> &[u8] {
let (mut even, mut odd) = (0usize, 0usize);
for (i, b) in bytes.iter().enumerate() {
if *b == 0 {
if i % 2 == 0 {
even += 1;
} else {
odd += 1;
}
}
}
if even > odd { &bytes[1..] } else { bytes }
}
fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, last: bool) { fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, last: bool) {
let mut src = bytes; let mut src = bytes;
loop { loop {
@@ -415,8 +180,7 @@ fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, las
#[cfg(test)] #[cfg(test)]
mod encoding_tests { mod encoding_tests {
use super::{MixedDecoder, NativeDecoder, OutputDecoder}; use super::OutputDecoder;
use ide_core::output_encoding::encoding_for_code_page;
#[test] #[test]
fn gbk_split_across_chunks() { fn gbk_split_across_chunks() {
@@ -438,157 +202,4 @@ mod encoding_tests {
assert_eq!(out, "héllo"); assert_eq!(out, "héllo");
assert!(matches!(d, OutputDecoder::Utf8(_))); assert!(matches!(d, OutputDecoder::Utf8(_)));
} }
fn mixed(cp: u32) -> MixedDecoder {
MixedDecoder::new(Some(NativeDecoder::for_code_page(cp).expect("code page")))
}
fn encode(cp: u32, text: &str) -> Vec<u8> {
let (bytes, _, unmappable) = encoding_for_code_page(cp).expect("code page").encode(text);
assert!(!unmappable, "{text} in {cp}");
bytes.into_owned()
}
/// Simplified and Traditional Chinese, Korean, Japanese, Cyrillic (OEM and ANSI), Western.
const SAMPLES: &[(u32, &str)] = &[
(936, "正在 Ping 127.0.0.1 具有 32 字节的数据:"),
(950, "系統找不到指定的路徑。"),
(949, "지정된 경로를 찾을 수 없습니다."),
(932, "指定されたパスが見つかりません。"),
(866, "Системе не удается найти указанный путь."),
(1251, "Системе не удается найти указанный путь."),
(1252, "Le chemin d'accès spécifié est introuvable."),
];
#[test]
fn utf8_and_code_page_lines_mix_whatever_the_chunks() {
for &(cp, native) in SAMPLES {
let mut bytes = " Compiling 编译 ü\n".as_bytes().to_vec();
bytes.extend(encode(cp, native));
bytes.extend_from_slice(b"\r\n");
bytes.extend_from_slice("ok ✓\n".as_bytes());
bytes.extend(encode(cp, native));
let expected = format!(" Compiling 编译 ü\n{native}\r\nok ✓\n{native}");
for split in 0..=bytes.len() {
let mut d = mixed(cp);
let mut out = String::new();
d.decode_into(&bytes[..split], &mut out);
d.decode_into(&bytes[split..], &mut out);
out.push_str(&d.finish());
assert_eq!(out, expected, "code page {cp}, split at {split}");
}
}
}
#[test]
fn byte_by_byte_code_page_output() {
for &(cp, native) in SAMPLES {
let bytes = [encode(cp, native), b"\n".to_vec()].concat();
let mut d = mixed(cp);
let mut out = String::new();
for b in &bytes {
d.decode_into(std::slice::from_ref(b), &mut out);
}
assert!(!d.holds());
assert_eq!(out, format!("{native}\n"), "code page {cp}");
}
}
#[test]
fn ascii_and_code_page_prompts_show_at_once_and_utf8_ones_on_release() {
let mut d = mixed(936);
let mut out = String::new();
d.decode_into(b"Password: ", &mut out);
assert_eq!(out, "Password: ");
assert!(!d.holds());
out.clear();
d.decode_into(&encode(936, "请按任意键继续. . ."), &mut out);
assert_eq!(out, "请按任意键继续. . .");
assert!(!d.holds());
let mut d = mixed(936);
out.clear();
d.decode_into("Continue? 继续 ".as_bytes(), &mut out);
assert_eq!(out, "Continue? ");
assert!(d.holds());
d.release(&mut out);
assert_eq!(out, "Continue? 继续 ");
d.decode_into("[y/N]\n".as_bytes(), &mut out);
assert_eq!(out, "Continue? 继续 [y/N]\n");
}
#[test]
fn a_released_line_split_inside_a_character_completes() {
let mut d = mixed(949);
let mut out = String::new();
let bytes = "é中".as_bytes();
d.decode_into(&bytes[..3], &mut out);
d.release(&mut out);
d.decode_into(&bytes[3..], &mut out);
out.push_str(&d.finish());
assert_eq!(out, "é中");
}
#[test]
fn utf8_code_page_has_no_mixed_decoder() {
assert!(NativeDecoder::for_code_page(65001).is_none());
}
fn utf16(text: &str) -> Vec<u8> {
text.encode_utf16().flat_map(u16::to_le_bytes).collect()
}
/// What `wsl.exe -l -v` writes, on a Chinese system.
const WSL: &str = " NAME STATE VERSION\r\n* Ubuntu-22.04 Running 2\r\n\
适用于 Linux 的 Windows 子系统: 默认\r\n";
#[test]
fn utf16_output_between_utf8_and_code_page_lines_whatever_the_chunks() {
let before = " Compiling 编译\n".as_bytes();
let wsl = utf16(WSL);
let after = [encode(936, "系统找不到指定的路径。"), "\r\ndone ✓\n".as_bytes().to_vec()].concat();
let expected = format!(" Compiling 编译\n{WSL}系统找不到指定的路径。\r\ndone ✓\n");
for split in 0..=wsl.len() {
let mut d = mixed(936);
let mut out = String::new();
for chunk in [before, &wsl[..split], &wsl[split..], &after] {
d.decode_into(chunk, &mut out);
}
out.push_str(&d.finish());
assert_eq!(out, expected, "split at {split}");
}
}
#[test]
fn utf16_with_a_byte_order_mark_or_starting_with_cjk() {
let mut d = mixed(949);
let mut out = String::new();
d.decode_into(&[&[0xFF, 0xFE][..], &utf16("한국어 OK\r\n")].concat(), &mut out);
d.decode_into(&utf16("中文 OK\r\n"), &mut out);
out.push_str(&d.finish());
assert_eq!(out, "한국어 OK\r\n中文 OK\r\n");
}
#[test]
fn without_a_code_page_other_lines_are_lossy_utf8_and_utf16_still_reads() {
let mut d = MixedDecoder::new(None);
let mut out = String::new();
d.decode_into(b"caf\xe9\n", &mut out);
d.decode_into(&utf16("wsl 子系统\r\n"), &mut out);
d.decode_into("ok ✓\n".as_bytes(), &mut out);
out.push_str(&d.finish());
assert_eq!(out, "caf\u{FFFD}\nwsl 子系统\r\nok ✓\n");
}
#[cfg(windows)]
#[test]
fn oem_code_pages_without_an_encoding_rs_table_decode_through_windows() {
for cp in [437, 850, 852] {
let mut d = mixed(cp);
let mut out = String::new();
d.decode_into(b"caf\x82\r\n", &mut out);
assert_eq!(out, "caf\u{e9}\r\n", "code page {cp}");
}
}
} }
-69
View File
@@ -1,69 +0,0 @@
#![cfg(windows)]
use ide_core::{IdeEvent, SharedSink};
use ide_run::{OutputChunk, ProcessManager, RunConfig, StartOptions};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::mpsc;
use std::time::Duration;
/// Stdout and stderr of a shell command line run on pipes in a UTF-8 console.
fn run(command: &str) -> String {
let (tx, events) = mpsc::channel();
let tx = Mutex::new(tx);
let sink: SharedSink = Arc::new(move |e: IdeEvent| {
let _ = tx.lock().send(e);
});
let root = tempfile::tempdir().unwrap();
let config = RunConfig {
id: "t:out".into(),
name: "t:out".into(),
kind: "shell".into(),
command: command.into(),
args: Vec::new(),
cwd: None,
env: BTreeMap::new(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
let chunks: Arc<Mutex<Vec<OutputChunk>>> = Arc::default();
let out = chunks.clone();
let pm = ProcessManager::new();
let info = pm
.start(root.path(), &config, &StartOptions::default(), sink, Box::new(move |c| out.lock().push(c)))
.expect("start");
loop {
match events.recv_timeout(Duration::from_secs(20)).expect("RunExited event") {
IdeEvent::RunExited { run, .. } if run == info.id => break,
_ => {}
}
}
chunks
.lock()
.iter()
.filter_map(|c| match c {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } => Some(text.as_str()),
_ => None,
})
.collect()
}
/// `cmd`'s own messages reach the pipe in the console code page (GBK, Shift_JIS, EUC-KR,
/// 437…, by the system's language), and still read right in a UTF-8 console.
#[test]
fn native_messages_in_the_console_code_page_are_decoded() {
let text = run(r"dir C:\openidea-no-such-dir");
assert!(text.trim().lines().count() >= 2, "{text:?}");
assert!(!text.contains('\u{FFFD}'), "{text:?}");
}
/// `cmd /u` writes UTF-16LE, as `wsl.exe` does; it reads right whatever the system's language.
#[test]
fn utf16_output_is_decoded() {
let text = run("cmd /u /c echo 中文 한국어 OK");
assert_eq!(text, "中文 한국어 OK\r\n");
}
-27
View File
@@ -323,18 +323,6 @@ fn resolve_gradle_command(root: &Path, build_dir: &Path, s: &Settings, use_wrapp
} }
let wrapper = build_dir.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" }); let wrapper = build_dir.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
if use_wrapper && wrapper.is_file() { if use_wrapper && wrapper.is_file() {
// `gradlew` can't start without its jar, which `.gitignore`s with `*.jar` often leave
// out: run the distribution the wrapper already unpacked instead.
if build_dir.join("gradle/wrapper/gradle-wrapper.jar").is_file() {
return wrapper;
}
let home = setting_path(root, &g.user_home)
.or_else(|| std::env::var_os("GRADLE_USER_HOME").map(PathBuf::from))
.or_else(|| Some(crate::home_dir()?.join(".gradle")));
if let Some(gradle) = home.and_then(|home| unpacked_wrapper_distribution(build_dir, &home)) {
return gradle;
}
// Nothing to run instead: the wrapper's failure explains what is missing.
return wrapper; return wrapper;
} }
pinned::pinned_install(build_dir, Tool::Gradle) pinned::pinned_install(build_dir, Tool::Gradle)
@@ -342,21 +330,6 @@ fn resolve_gradle_command(root: &Path, build_dir: &Path, s: &Settings, use_wrapp
.unwrap_or_else(|| name.into()) .unwrap_or_else(|| name.into())
} }
/// `bin/gradle` of the distribution `gradle/wrapper/gradle-wrapper.properties` names, once the
/// wrapper has unpacked it in `<gradle_user_home>/wrapper/dists` (and marked it complete).
fn unpacked_wrapper_distribution(build_dir: &Path, gradle_user_home: &Path) -> Option<PathBuf> {
let properties = std::fs::read_to_string(build_dir.join("gradle/wrapper/gradle-wrapper.properties")).ok()?;
let url = properties.lines().find_map(|line| line.trim().strip_prefix("distributionUrl="))?;
let zip = url.trim().replace("\\:", ":").rsplit('/').next()?.to_owned();
let name = zip.strip_suffix(".zip")?;
let gradle = if cfg!(windows) { "gradle.bat" } else { "gradle" };
// One directory per distribution URL hash, each holding the unpacked `gradle-<version>`.
let hashes = std::fs::read_dir(gradle_user_home.join("wrapper/dists").join(name)).ok()?;
hashes.flatten().map(|e| e.path()).filter(|dir| dir.join(format!("{zip}.ok")).is_file()).find_map(|dir| {
std::fs::read_dir(&dir).ok()?.flatten().map(|e| e.path().join("bin").join(gradle)).find(|p| p.is_file())
})
}
fn gradle_run_jdk<'a>( fn gradle_run_jdk<'a>(
release: Option<u32>, release: Option<u32>,
configured: Option<&'a crate::java::Jdk>, configured: Option<&'a crate::java::Jdk>,
-31
View File
@@ -1866,37 +1866,6 @@ fn max_java_picks_an_older_jdk() {
assert_eq!(free.env["JAVA_HOME"], j25.home.to_string_lossy()); assert_eq!(free.env["JAVA_HOME"], j25.home.to_string_lossy());
} }
/// #3086: `gradle-wrapper.jar` excluded by a `*.jar` rule in `.gitignore`: `gradlew` can't
/// start, so the distribution the wrapper already unpacked runs instead.
#[test]
fn gradle_wrapper_without_its_jar_runs_the_unpacked_distribution() {
use crate::apply::gradle_command;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let plain = if cfg!(windows) { "gradle.bat" } else { "gradle" };
let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
std::fs::write(&wrapper, "").unwrap();
std::fs::create_dir_all(root.join("gradle/wrapper")).unwrap();
std::fs::write(
root.join("gradle/wrapper/gradle-wrapper.properties"),
"distributionUrl=https\\://services.gradle.org/distributions/gradle-4.10-bin.zip\n",
)
.unwrap();
let mut settings = Settings::default();
settings.languages.java.gradle.user_home = "user-home".into();
let hash = root.join("user-home/wrapper/dists/gradle-4.10-bin/7ddd1b3f");
let gradle = hash.join("gradle-4.10/bin").join(plain);
std::fs::create_dir_all(gradle.parent().unwrap()).unwrap();
std::fs::write(&gradle, "").unwrap();
// Not marked complete: possibly half unpacked.
assert_eq!(gradle_command(root, root, &settings), wrapper);
std::fs::write(hash.join("gradle-4.10-bin.zip.ok"), "").unwrap();
assert_eq!(gradle_command(root, root, &settings), gradle);
// With its jar the wrapper runs as usual.
std::fs::write(root.join("gradle/wrapper/gradle-wrapper.jar"), "").unwrap();
assert_eq!(gradle_command(root, root, &settings), wrapper);
}
#[test] #[test]
fn gradle_settings_precedence() { fn gradle_settings_precedence() {
use crate::apply::{gradle_command, gradle_env}; use crate::apply::{gradle_command, gradle_env};
+15 -69
View File
@@ -17,18 +17,11 @@ pub(crate) const EXTENSION: &str = "vue";
const CLI_SERVICE: &str = "@vue/cli-service"; const CLI_SERVICE: &str = "@vue/cli-service";
const CLI_CONFIG_TIMEOUT: Duration = Duration::from_secs(30); const CLI_CONFIG_TIMEOUT: Duration = Duration::from_secs(30);
const ALIASES_MARKER: &str = "openidea-webpack-aliases:"; const ALIASES_MARKER: &str = "openidea-webpack-aliases:";
/// Prints the `resolve.alias` of the webpack configuration module `argv[1]` (an object, an /// Prints the `resolve.alias` of the webpack configuration module `argv[1]`, after whatever
/// ES module's default, a function of the environment, or a list of configurations), after /// the configuration itself prints.
/// whatever the configuration itself prints. const PRINT_ALIASES: &str = "const c = require(process.argv[1]); const a = (c.resolve && c.resolve.alias) || {}; \
const PRINT_ALIASES: &str = "let c = require(process.argv[1]); if (c && c.__esModule) c = c.default; \
if (typeof c === 'function') c = c({}, { mode: 'development' }); if (Array.isArray(c)) c = c[0]; \
const a = (c && c.resolve && c.resolve.alias) || {}; \
const e = Array.isArray(a) ? a.map((x) => [x.name, x.alias]) : Object.entries(a); \ const e = Array.isArray(a) ? a.map((x) => [x.name, x.alias]) : Object.entries(a); \
process.stdout.write('\\n' + process.argv[2] + JSON.stringify(e));"; process.stdout.write('\\n' + process.argv[2] + JSON.stringify(e));";
/// Webpack configurations of Vue projects without Vue CLI: the project's own, and the
/// `vue init webpack` (Vue CLI 2) template's.
const WEBPACK_CONFIGS: &[&str] =
&["webpack.config.js", "webpack.config.cjs", "build/webpack.base.conf.js", "build/webpack.dev.conf.js"];
type Paths = BTreeMap<String, Vec<String>>; type Paths = BTreeMap<String, Vec<String>>;
static CLI_PATHS: LazyLock<Mutex<HashMap<PathBuf, (Stamp, Paths)>>> = LazyLock::new(Default::default); static CLI_PATHS: LazyLock<Mutex<HashMap<PathBuf, (Stamp, Paths)>>> = LazyLock::new(Default::default);
@@ -49,33 +42,24 @@ pub(crate) fn tsserver_plugin(package: &Path) -> serde_json::Value {
}) })
} }
/// `compilerOptions.paths` for the webpack aliases of a Vue project at `root` (`@/*` → /// `compilerOptions.paths` for the webpack aliases of a Vue CLI project at `root` (`@/*` →
/// `<root>/src/*`), for its tsserver project when it has no `jsconfig.json`: tsserver can't /// `<root>/src/*`), for its tsserver project when it has no `jsconfig.json`: tsserver can't
/// read webpack configurations, so `@/` imports resolve to nothing, and a mixin imported /// read webpack configurations, so `@/` imports resolve to nothing, and a mixin imported
/// that way makes the component's whole `this` `any`. Like IntelliJ, evaluates the webpack /// that way makes the component's whole `this` `any`. Like IntelliJ, evaluates the
/// configuration (the project is trusted: language servers only run then): the one /// configuration `@vue/cli-service` resolves with the project's `vue.config.js` (the project
/// `@vue/cli-service` resolves with the project's `vue.config.js`, else the project's own /// is trusted: language servers only run then); else the alias Vue CLI always adds.
/// (`webpack.config.js`, Vue CLI 2's `build/webpack.base.conf.js`). Without one (Vite, or /// Empty for other projects.
/// no Node), the `@` → `src` alias every Vue template sets up. Empty for other projects.
pub(crate) fn cli_paths(root: &Path, node: Option<&Path>) -> Paths { pub(crate) fn cli_paths(root: &Path, node: Option<&Path>) -> Paths {
let Ok(manifest) = std::fs::read_to_string(root.join("package.json")) else { return Paths::new() }; let Ok(manifest) = std::fs::read_to_string(root.join("package.json")) else { return Paths::new() };
let dependencies = package_dependencies(&manifest); if !package_dependencies(&manifest).iter().any(|d| d == CLI_SERVICE) {
let cli = dependencies.iter().any(|d| d == CLI_SERVICE);
if !cli && !dependencies.iter().any(|d| d == "vue") {
return Paths::new(); return Paths::new();
} }
let config = if cli { let config = root
root.ancestors() .ancestors()
.map(|dir| dir.join("node_modules").join(CLI_SERVICE).join("webpack.config.js")) .map(|dir| dir.join("node_modules").join(CLI_SERVICE).join("webpack.config.js"))
.find(|f| f.is_file()) .find(|f| f.is_file());
} else { let mut inputs: Vec<PathBuf> =
WEBPACK_CONFIGS.iter().map(|f| root.join(f)).find(|f| f.is_file()) ["package.json", "vue.config.js", "vue.config.cjs", "vue.config.mjs"].iter().map(|f| root.join(f)).collect();
};
let mut inputs: Vec<PathBuf> = ["package.json", "vue.config.js", "vue.config.cjs", "vue.config.mjs"]
.iter()
.chain(WEBPACK_CONFIGS)
.map(|f| root.join(f))
.collect();
inputs.extend(config.iter().cloned()); inputs.extend(config.iter().cloned());
inputs.extend(node.map(Path::to_path_buf)); inputs.extend(node.map(Path::to_path_buf));
let now = stamp(&inputs); let now = stamp(&inputs);
@@ -87,7 +71,6 @@ pub(crate) fn cli_paths(root: &Path, node: Option<&Path>) -> Paths {
let aliases = config let aliases = config
.zip(node) .zip(node)
.and_then(|(config, node)| webpack_aliases(root, &config, node)) .and_then(|(config, node)| webpack_aliases(root, &config, node))
.filter(|aliases| !aliases.is_empty())
.unwrap_or_else(|| vec![("@".into(), root.join("src").to_string_lossy().into_owned())]); .unwrap_or_else(|| vec![("@".into(), root.join("src").to_string_lossy().into_owned())]);
let paths = alias_paths(&aliases); let paths = alias_paths(&aliases);
CLI_PATHS.lock().unwrap_or_else(|e| e.into_inner()).insert(root.to_path_buf(), (now, paths.clone())); CLI_PATHS.lock().unwrap_or_else(|e| e.into_inner()).insert(root.to_path_buf(), (now, paths.clone()));
@@ -198,43 +181,6 @@ mod tests {
); );
} }
#[test]
fn other_vue_projects_get_their_webpack_aliases_else_the_at_alias() {
let tmp = tempfile::tempdir().unwrap();
// As `__dirname` sees it (macOS: /private/var).
let t = &dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(t.join("package.json"), r#"{"dependencies":{"react":"^18.0.0"}}"#).unwrap();
std::fs::create_dir_all(t.join("src/views")).unwrap();
assert!(cli_paths(t, None).is_empty(), "not a Vue project");
std::fs::write(t.join("package.json"), r#"{"dependencies":{"vue":"^2.6.14"}}"#).unwrap();
let slash = |p: PathBuf| p.to_string_lossy().replace('\\', "/");
let (src, views) = (slash(t.join("src")), slash(t.join("src/views")));
let at = Paths::from([("@".into(), vec![src.clone()]), ("@/*".into(), vec![format!("{src}/*")])]);
assert_eq!(cli_paths(t, None), at, "Vite or no Node: the template alias");
let Some(node) = crate::exec::which("node") else { return };
// `vue init webpack`: a function of the environment is called, its aliases taken.
std::fs::create_dir_all(t.join("build")).unwrap();
std::fs::write(
t.join("build/webpack.base.conf.js"),
r#"const path = require('path');
const resolve = (dir) => path.join(__dirname, '..', dir);
module.exports = (env) => ({ resolve: { alias: { vue$: 'vue/dist/vue.esm.js', '@': resolve('src'), views: resolve('src/views') } } });"#,
)
.unwrap();
assert_eq!(
cli_paths(t, Some(&node)),
Paths::from([
("@".into(), vec![src.clone()]),
("@/*".into(), vec![format!("{src}/*")]),
("views".into(), vec![views.clone()]),
("views/*".into(), vec![format!("{views}/*")]),
])
);
// A configuration that can't be loaded (dependencies not installed): the template alias.
std::fs::write(t.join("build/webpack.base.conf.js"), "require('not-installed-plugin');").unwrap();
assert_eq!(cli_paths(t, Some(&node)), at);
}
#[test] #[test]
fn vue_cli_aliases_come_from_the_resolved_webpack_configuration() { fn vue_cli_aliases_come_from_the_resolved_webpack_configuration() {
let Some(node) = crate::exec::which("node") else { return }; let Some(node) = crate::exec::which("node") else { return };
+2
View File
@@ -19,6 +19,8 @@ tar = "0.4"
zip.workspace = true zip.workspace = true
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
[dev-dependencies]
tempfile.workspace = true tempfile.workspace = true
[target.'cfg(unix)'.dev-dependencies] [target.'cfg(unix)'.dev-dependencies]
-2
View File
@@ -20,8 +20,6 @@ mod bundled;
pub mod formatter; pub mod formatter;
pub mod jdk; pub mod jdk;
pub mod platform; pub mod platform;
#[cfg(any(windows, test))]
pub mod portable_update;
mod process; mod process;
pub mod registry; pub mod registry;
pub mod server; pub mod server;
-223
View File
@@ -1,223 +0,0 @@
//! In-place updates of the Windows unpack-and-run distribution.
use ide_core::{IdeError, IdeResult};
use std::fs;
use std::io::{self, Cursor};
use std::path::{Path, PathBuf};
/// `bytes` must already have passed the updater's signature verification.
pub fn install(exe: &Path, bytes: &[u8]) -> IdeResult<()> {
let directory = exe.parent().ok_or_else(|| IdeError::invalid("executable has no parent directory"))?;
let staging = tempfile::Builder::new().prefix(".openidea-update-").tempdir_in(directory)?;
let files = unpack(bytes, staging.path(), exe)?;
let mut replaced = Vec::new();
for (source, target) in files {
match replace_file(&source, &target) {
Ok(backup) => replaced.push((target, backup)),
Err(error) => {
for (target, backup) in replaced.into_iter().rev() {
fs::remove_file(&target)?;
if let Some(backup) = backup {
fs::rename(&backup, &target).map_err(|restore| {
IdeError::internal(format!(
"{error}; restore {} from {}: {restore}",
target.display(),
backup.display()
))
})?;
}
}
return Err(error);
}
}
}
Ok(())
}
/// Old executables stay locked until their process exits; retry cleanup on the next start.
pub fn cleanup(exe: &Path) {
for target in [exe.to_path_buf(), exe.with_file_name("WebView2Loader.dll")] {
let _ = fs::remove_file(backup_path(&target));
}
}
fn unpack(bytes: &[u8], staging: &Path, exe: &Path) -> IdeResult<Vec<(PathBuf, PathBuf)>> {
let invalid = |error| IdeError::invalid(format!("portable update archive: {error}"));
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(invalid)?;
let mut files = Vec::new();
let mut has_exe = false;
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(invalid)?;
let name = entry.name().map_err(invalid)?;
let target = match name.as_ref() {
"openidea.exe" => {
has_exe = true;
exe.to_path_buf()
}
"WebView2Loader.dll" => exe.with_file_name("WebView2Loader.dll"),
name => return Err(IdeError::invalid(format!("unexpected portable update file: {name}"))),
};
if !entry.is_file() || entry.is_symlink() || files.iter().any(|(_, path)| path == &target) {
return Err(IdeError::invalid("portable update contains a duplicate or non-regular file"));
}
let source = staging.join(name.as_ref());
let mut file = fs::File::create(&source)?;
io::copy(&mut entry, &mut file)?;
file.sync_all()?;
files.push((source, target));
}
if !has_exe {
return Err(IdeError::invalid("portable update is missing openidea.exe"));
}
Ok(files)
}
fn replace_file(source: &Path, target: &Path) -> IdeResult<Option<PathBuf>> {
let backup = if target.try_exists()? {
if !target.is_file() {
return Err(IdeError::invalid(format!("not an application file: {}", target.display())));
}
let backup = backup_path(target);
if backup.try_exists()? {
fs::remove_file(&backup)?;
}
// Windows forbids overwriting a running exe, but allows renaming it.
fs::rename(target, &backup)?;
Some(backup)
} else {
None
};
if let Err(error) = fs::rename(source, target) {
if let Some(backup) = &backup {
fs::rename(backup, target).map_err(|restore| {
IdeError::internal(format!(
"{error}; restore {} from {}: {restore}",
target.display(),
backup.display()
))
})?;
}
return Err(error.into());
}
Ok(backup)
}
fn backup_path(target: &Path) -> PathBuf {
let mut path = target.as_os_str().to_os_string();
path.push(".openidea-old");
PathBuf::from(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn package(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, bytes) in files {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn replaces_application_files_and_preserves_configuration() {
for name in ["openidea.exe", "我的 IDE.exe"] {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join(name);
fs::write(&exe, b"old exe").unwrap();
fs::write(tmp.path().join("WebView2Loader.dll"), b"old dll").unwrap();
fs::write(tmp.path().join("settings.toml"), b"my settings").unwrap();
fs::create_dir(tmp.path().join("data")).unwrap();
fs::write(tmp.path().join("data/plugins.json"), b"my plugins").unwrap();
let bytes = package(&[("openidea.exe", b"new exe"), ("WebView2Loader.dll", b"new dll")]);
install(&exe, &bytes).unwrap();
assert_eq!(fs::read(&exe).unwrap(), b"new exe");
assert_eq!(fs::read(tmp.path().join("WebView2Loader.dll")).unwrap(), b"new dll");
assert_eq!(fs::read(tmp.path().join("settings.toml")).unwrap(), b"my settings");
assert_eq!(fs::read(tmp.path().join("data/plugins.json")).unwrap(), b"my plugins");
cleanup(&exe);
assert!(!backup_path(&exe).exists());
assert!(!backup_path(&tmp.path().join("WebView2Loader.dll")).exists());
}
}
#[test]
fn accepts_an_exe_only_package() {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
fs::write(&exe, b"old").unwrap();
install(&exe, &package(&[("openidea.exe", b"new")])).unwrap();
assert_eq!(fs::read(&exe).unwrap(), b"new");
}
#[test]
fn rejects_incomplete_or_unexpected_payloads_before_changing_files() {
for files in [
vec![("WebView2Loader.dll", b"new".as_slice())],
vec![("openidea.exe", b"new"), ("settings.toml", b"overwrite")],
vec![("openidea.exe", b"new"), ("../outside.txt", b"escape")],
] {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
fs::write(&exe, b"old").unwrap();
assert!(install(&exe, &package(&files)).is_err());
assert_eq!(fs::read(&exe).unwrap(), b"old");
}
}
#[cfg(windows)]
#[test]
fn updates_a_running_executable_and_restores_on_a_locked_companion() {
use std::os::windows::fs::OpenOptionsExt;
use std::process::{Command, Stdio};
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
let ping = Path::new(&std::env::var_os("SystemRoot").unwrap()).join("System32/PING.EXE");
fs::copy(ping, &exe).unwrap();
let original = fs::read(&exe).unwrap();
let dll = tmp.path().join("WebView2Loader.dll");
fs::write(&dll, b"old dll").unwrap();
let mut command = Command::new(&exe);
command.args(["-n", "30", "127.0.0.1"]).stdout(Stdio::null()).stderr(Stdio::null());
ide_core::process::prepare(&mut command);
let mut child = command.spawn().unwrap();
let result = {
assert!(fs::write(&exe, b"overwrite").is_err(), "fixture must hold the running exe");
let held = fs::OpenOptions::new().read(true).share_mode(1).open(&dll).unwrap();
let bytes = package(&[("openidea.exe", b"new exe"), ("WebView2Loader.dll", b"new dll")]);
assert!(install(&exe, &bytes).is_err());
assert_eq!(fs::read(&exe).unwrap(), original);
assert_eq!(fs::read(&dll).unwrap(), b"old dll");
drop(held);
install(&exe, &bytes)
};
let _ = child.kill();
let _ = child.wait();
result.unwrap();
assert_eq!(fs::read(&exe).unwrap(), b"new exe");
assert_eq!(fs::read(&dll).unwrap(), b"new dll");
cleanup(&exe);
assert!(!backup_path(&exe).exists());
}
#[cfg(windows)]
#[test]
fn restores_the_old_file_when_the_new_file_cannot_be_moved() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
let source = tmp.path().join("new.exe");
fs::write(&exe, b"old").unwrap();
fs::write(&source, b"new").unwrap();
let _held = fs::OpenOptions::new().read(true).share_mode(1).open(&source).unwrap();
assert!(replace_file(&source, &exe).is_err());
assert_eq!(fs::read(&exe).unwrap(), b"old");
assert_eq!(fs::read(&source).unwrap(), b"new");
assert!(!backup_path(&exe).exists());
}
}
-3
View File
@@ -281,9 +281,6 @@ impl Repo {
let commitish = arg(commitish, "revision")?; let commitish = arg(commitish, "revision")?;
let mut args: Vec<String> = vec!["worktree".into(), "add".into()]; let mut args: Vec<String> = vec!["worktree".into(), "add".into()];
if let Some(b) = new_branch.map(str::trim).filter(|b| !b.is_empty()) { if let Some(b) = new_branch.map(str::trim).filter(|b| !b.is_empty()) {
if crate::write::renamed_remote_start(self, b, &commitish) {
args.push("--no-track".into());
}
args.extend(["-b".into(), arg(b, "branch name")?]); args.extend(["-b".into(), arg(b, "branch name")?]);
} }
args.extend(["--".into(), path, commitish]); args.extend(["--".into(), path, commitish]);
+2 -27
View File
@@ -800,41 +800,16 @@ pub(crate) fn create_branch(
let repo = r.gix()?; let repo = r.gix()?;
resolve(&repo, s)?; resolve(&repo, s)?;
} }
let no_track = start.is_some_and(|s| renamed_remote_start(r, name, s));
let mut args = match (checkout, overwrite) { let mut args = match (checkout, overwrite) {
(true, false) => vec!["switch", "-c", name], (true, false) => vec!["switch", "-c", name],
(true, true) => vec!["switch", "-C", name], (true, true) => vec!["switch", "-C", name],
(false, false) => vec!["branch"], (false, false) => vec!["branch", "--", name],
(false, true) => vec!["branch", "--force"], (false, true) => vec!["branch", "--force", "--", name],
}; };
if no_track {
args.push("--no-track");
}
if !checkout {
args.extend(["--", name]);
}
args.extend(start); args.extend(start);
run(r.root(), &args, None).map(|_| ()) run(r.root(), &args, None).map(|_| ())
} }
/// Whether `start` is a remote-tracking branch with another name than the new branch
/// `name`. Git would make it the upstream, so pushing the new branch would go to the
/// start branch (`feature` from `origin/main` pushes to `main`); such a branch is left
/// without upstream and gets its own one on the first push. A remote branch under the
/// same name stays tracked.
pub(crate) fn renamed_remote_start(r: &Repo, name: &str, start: &str) -> bool {
let Ok(out) = cli::run_raw(r.root(), &["rev-parse", "--verify", "--quiet", "--symbolic-full-name", start], None)
else {
return false;
};
let full = out.stdout_str().trim().to_string();
let Some(rest) = full.strip_prefix("refs/remotes/").filter(|_| out.code == Some(0)) else { return false };
let Ok(repo) = r.gix() else { return false };
let names = repo.remote_names();
let branch = names.iter().find_map(|remote| rest.strip_prefix(&format!("{remote}/")));
branch.is_some_and(|b| b != name)
}
/// Undo Commit: moves the current branch back to the parent of `commit` (which must be /// Undo Commit: moves the current branch back to the parent of `commit` (which must be
/// HEAD), keeping its changes staged. Returns the undone commit's message. /// HEAD), keeping its changes staged. Returns the undone commit's message.
/// Edit Commit Message: writes `commit` again with `message`, and its descendants on top /// Edit Commit Message: writes `commit` again with `message`, and its descendants on top
+3 -29
View File
@@ -984,41 +984,15 @@ fn branch_overwrite_preserves_the_ref_if_checkout_fails() {
} }
#[test] #[test]
fn new_branch_from_remote_branch_tracks_it_only_under_the_same_name() { fn new_branch_from_remote_branch_tracks_it() {
let (fx, bare) = Fixture::with_origin(); let (fx, _bare) = Fixture::with_origin();
fx.git(&["push", "-q", "origin", "main:release"]); fx.git(&["push", "-q", "origin", "main:release"]);
fx.git(&["fetch", "-q"]); fx.git(&["fetch", "-q"]);
let repo = fx.repo(); let repo = fx.repo();
let upstream = |name: &str| {
repo.branches().unwrap().into_iter().find(|b| b.name == name && !b.remote).and_then(|b| b.upstream)
};
repo.create_branch("release", Some("origin/release"), false).unwrap();
assert_eq!(upstream("release").as_deref(), Some("origin/release"));
// Another name: no upstream, whether switched to or not, created plainly or overwritten.
repo.create_branch("other", Some("origin/release"), false).unwrap();
assert_eq!(upstream("other"), None);
repo.create_branch_with_overwrite("other", Some("origin/main"), false, true).unwrap();
assert_eq!(upstream("other"), None);
repo.create_branch("my-release", Some("origin/release"), true).unwrap(); repo.create_branch("my-release", Some("origin/release"), true).unwrap();
let info = repo.branch_info().unwrap(); let info = repo.branch_info().unwrap();
assert_eq!(info.head.as_deref(), Some("my-release")); assert_eq!(info.head.as_deref(), Some("my-release"));
assert_eq!(info.upstream, None); assert_eq!(info.upstream.as_deref(), Some("origin/release"));
// The first push then goes to a branch of its own name and tracks it.
fx.write("a.txt", "mine\n");
let tip = fx.commit_all("mine");
let (cancel, out) = quiet();
repo.push(false, true, &cancel, &out).unwrap();
assert_eq!(git_in(&bare, &["rev-parse", "my-release"]), tip);
assert_ne!(git_in(&bare, &["rev-parse", "release"]), tip);
assert_eq!(fx.git(&["config", "branch.my-release.merge"]), "refs/heads/my-release");
// New worktree on a new branch from a remote branch: the same rule.
let wt = fx.tmp.path().join("wt");
repo.add_worktree(&wt, "origin/release", Some("wt-branch")).unwrap();
assert_eq!(upstream("wt-branch"), None);
} }
#[test] #[test]
@@ -61,9 +61,8 @@ pub fn artifacts_url(group: &str, prefix: &str) -> Option<String> {
/// The published version metadata of one artifact on Central. /// The published version metadata of one artifact on Central.
pub fn metadata_url(group: &str, artifact: &str) -> Option<String> { pub fn metadata_url(group: &str, artifact: &str) -> Option<String> {
// The group's dots are directories; an artifact id is one directory, dots and all. (part(group, 200) && part(artifact, 100)).then(|| {
(part(group, 200) && part(artifact, 100) && artifact != "." && artifact != "..").then(|| { let path = group.split('.').chain(artifact.split('.')).collect::<Vec<_>>().join("/");
let path = group.split('.').chain([artifact]).collect::<Vec<_>>().join("/");
format!("https://repo1.maven.org/maven2/{path}/maven-metadata.xml") format!("https://repo1.maven.org/maven2/{path}/maven-metadata.xml")
}) })
} }
@@ -147,11 +146,6 @@ mod tests {
metadata_url("org.apache.maven", "maven-core").as_deref(), metadata_url("org.apache.maven", "maven-core").as_deref(),
Some("https://repo1.maven.org/maven2/org/apache/maven/maven-core/maven-metadata.xml") Some("https://repo1.maven.org/maven2/org/apache/maven/maven-core/maven-metadata.xml")
); );
assert_eq!(
metadata_url("org.eclipse.platform", "org.eclipse.core.runtime").as_deref(),
Some("https://repo1.maven.org/maven2/org/eclipse/platform/org.eclipse.core.runtime/maven-metadata.xml")
);
assert_eq!(metadata_url("org.example", ".."), None);
} }
#[test] #[test]
@@ -389,12 +389,6 @@ impl Documents {
self.apply_workspace_edit_inner(edit, true, &mut |_| Ok(())) self.apply_workspace_edit_inner(edit, true, &mut |_| Ok(()))
} }
/// [`Self::apply_workspace_edit`] for an agent's edit (the MCP server's tools): undoable the
/// same way, announced as an agent's (`by_agent`) rather than as a refactoring.
pub fn apply_agent_workspace_edit(&self, edit: &WorkspaceEdit) -> IdeResult<()> {
self.apply_workspace_edit_as(edit, true, true, &mut |_| Ok(()))
}
/// File moves already own a combined move/reference undo record. /// File moves already own a combined move/reference undo record.
pub fn apply_file_move_edits(&self, edit: &WorkspaceEdit) -> IdeResult<()> { pub fn apply_file_move_edits(&self, edit: &WorkspaceEdit) -> IdeResult<()> {
self.apply_workspace_edit_inner(edit, false, &mut |_| Ok(())) self.apply_workspace_edit_inner(edit, false, &mut |_| Ok(()))
@@ -405,16 +399,6 @@ impl Documents {
edit: &WorkspaceEdit, edit: &WorkspaceEdit,
remember: bool, remember: bool,
before_operation: &mut dyn FnMut(usize) -> IdeResult<()>, before_operation: &mut dyn FnMut(usize) -> IdeResult<()>,
) -> IdeResult<()> {
self.apply_workspace_edit_as(edit, remember, false, before_operation)
}
fn apply_workspace_edit_as(
&self,
edit: &WorkspaceEdit,
remember: bool,
by_agent: bool,
before_operation: &mut dyn FnMut(usize) -> IdeResult<()>,
) -> IdeResult<()> { ) -> IdeResult<()> {
let _operation = self.operations.lock(); let _operation = self.operations.lock();
if edit.files.is_empty() { if edit.files.is_empty() {
@@ -501,7 +485,7 @@ impl Documents {
}); });
} }
history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE);
self.sink.emit(IdeEvent::WorkspaceEditApplied { id, paths: affected, by_agent }); self.sink.emit(IdeEvent::WorkspaceEditApplied { id, paths: affected });
} }
Ok(()) Ok(())
} }
+1 -16
View File
@@ -108,9 +108,7 @@ fn discover_with_limit(
if name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) { if name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
continue; continue;
} }
if entry.file_type().is_ok_and(|kind| kind.is_dir()) if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
&& crate::roots::virtual_env_marker(&entry.path()).is_none()
{
children.push(entry.path()); children.push(entry.path());
} }
} }
@@ -172,19 +170,6 @@ mod tests {
assert_eq!(result.direct[0], root); assert_eq!(result.direct[0], root);
} }
#[test]
fn skips_virtual_environments_whatever_their_name() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
project(root, "code10", "pyvenv.cfg");
project(root, "code10/Lib/site-packages/pkg", "pyproject.toml");
fs::create_dir_all(root.join("conda/conda-meta")).unwrap();
project(root, "conda/pkgs/dep", "setup.py");
let app = project(root, "app", "pyproject.toml");
let result = discover(vec![root.into()], &CancellationToken::new()).unwrap();
assert_eq!(result.projects.iter().map(|p| p.path.to_path_buf()).collect::<Vec<_>>(), [app]);
}
#[test] #[test]
fn finds_depth_four_but_not_five_and_retains_empty_folders() { fn finds_depth_four_but_not_five_and_retains_empty_folders() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
+1 -53
View File
@@ -17,19 +17,6 @@ const PYTHON_FILES: &[&str] = &["pyproject.toml", "setup.py", "setup.cfg", "Pipf
const SKIP_DIRS: &[&str] = const SKIP_DIRS: &[&str] =
&["node_modules", "target", "build", "dist", "out", "venv", "__pycache__", "vendor", "bower_components"]; &["node_modules", "target", "build", "dist", "out", "venv", "__pycache__", "vendor", "bower_components"];
/// A Python virtual environment (`python -m venv`, virtualenv, uv) or conda environment,
/// whatever it is named: its packages are dependencies, not modules of the project.
/// Returns the file that marks it.
pub(crate) fn virtual_env_marker(dir: &Path) -> Option<&'static str> {
if dir.join("pyvenv.cfg").is_file() {
Some("pyvenv.cfg")
} else if dir.join("conda-meta").is_dir() {
Some("conda-meta")
} else {
None
}
}
/// Build files whose changes can change the detected roots. /// Build files whose changes can change the detected roots.
pub(crate) const BUILD_FILES: &[&str] = &[ pub(crate) const BUILD_FILES: &[&str] = &[
"pom.xml", "pom.xml",
@@ -78,10 +65,7 @@ pub fn affects_roots(rel: &Path) -> bool {
let Some(name) = names.next() else { return false }; let Some(name) = names.next() else { return false };
BUILD_FILES.contains(&name) BUILD_FILES.contains(&name)
|| ROOT_NAMES.contains(&name) || ROOT_NAMES.contains(&name)
|| matches!( || matches!(name, "Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt")
name,
"Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt" | "pyvenv.cfg" | "conda-meta"
)
|| names.next() == Some("src") || names.next() == Some("src")
} }
@@ -147,13 +131,6 @@ pub fn detect(root: &Path) -> Vec<SourceFolder> {
if found.iter().any(|f| *f == child || f.is_empty() && name == "src") { if found.iter().any(|f| *f == child || f.is_empty() && name == "src") {
continue; continue;
} }
if let Some(marker) = virtual_env_marker(&root.join(&child)) {
if !out.iter().any(|r| r.path == child) {
let from = join(&child, marker);
out.push(SourceFolder { path: child, kind: RootKind::Excluded, detected_from: Some(from) });
}
continue;
}
queue.push_back((child, depth + 1)); queue.push_back((child, depth + 1));
} }
} }
@@ -528,35 +505,6 @@ mod tests {
); );
} }
#[test]
fn virtual_environments_are_excluded_whatever_their_name() {
// A package in the environment's site-packages has build files of its own; as a
// source root it would be put on PYTHONPATH and shadow the standard library.
let dir = tree(&[
"requirements.txt",
"app.py",
"code10/pyvenv.cfg",
"code10/Lib/site-packages/pkg/pyproject.toml",
"code10/Lib/site-packages/pkg/io/__init__.py",
"envs/conda/conda-meta/",
"envs/conda/lib/pkg/setup.py",
"svc/pyproject.toml",
"svc/env/pyvenv.cfg",
"svc/env/lib/python3.12/site-packages/dep/setup.py",
]);
assert_eq!(
summary(&detect(dir.path())),
vec![
"[email protected]",
"code10=Excluded@code10/pyvenv.cfg",
"envs/conda=Excluded@envs/conda/conda-meta",
"svc=Source@svc/pyproject.toml",
"svc/env=Excluded@svc/env/pyvenv.cfg",
]
);
assert!(affects_roots(Path::new("code10/pyvenv.cfg")));
}
#[test] #[test]
fn first_ecosystem_wins_for_a_shared_folder() { fn first_ecosystem_wins_for_a_shared_folder() {
// A Gradle build with a frontend: `build` stays Gradle's, `src` is not a Node root. // A Gradle build with a frontend: `build` stays Gradle's, `src` is not a Node root.
+1 -1
View File
@@ -6,7 +6,7 @@
//! project-relative and `/`-separated; `""` is the project root itself. //! project-relative and `/`-separated; `""` is the project root itself.
mod detect; mod detect;
pub(crate) use detect::{BUILD_FILES, virtual_env_marker}; pub(crate) use detect::BUILD_FILES;
mod gradle; mod gradle;
mod java; mod java;
mod maven; mod maven;
Loaded 100 of 352 files, more files were not shown because too many files have changed in this diff. Show more