diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..b7008354 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,69 @@ +name: Bug report +description: Something in OpenIDEA doesn't work as expected +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for reporting! Keep logs and error messages exactly as they appear. + + One problem per issue, please. If you have several, open one issue for each. + - type: checkboxes + id: checks + attributes: + label: Before you start + options: + - label: I searched [existing issues](https://github.com/l0ng-ai/openidea/issues?q=is%3Aissue) and this hasn't been reported yet. + required: true + - label: I'm on the [latest release](https://github.com/l0ng-ai/openidea/releases/latest), or I noted below that I can't update. + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: | + Open **About OpenIDEA** (macOS: OpenIDEA menu; Windows/Linux: Help menu), click **Copy Info**, and paste it here. Also mention the project type if it matters (e.g. Gradle multi-module, Spring Boot, Go workspace) and whether you use remote development. + render: text + placeholder: | + About OpenIDEA + Version: 0.1.33 + Build: ... + Platform: macOS aarch64 + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Start from opening OpenIDEA or a project. A small sample project or snippet helps a lot. + placeholder: | + 1. Open a Gradle project whose settings.gradle includes modules in a loop + 2. Open the Gradle tool window + 3. ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What happened instead. Screenshots or a recording are welcome. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs + description: | + Use **Help → Show Log in Finder / Explorer / Files** to find `openidea.log` and paste the lines around the problem, or attach the file. Include console output or error messages too. Check for tokens or passwords before posting. + render: text + - type: textarea + id: extra + attributes: + label: Additional context + description: Anything else, such as whether it worked in an earlier version. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..c6bb3f1a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Download the latest release + url: https://github.com/l0ng-ai/openidea/releases/latest + about: Many problems are already fixed. Please check the latest version before reporting. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..bc9ad784 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,38 @@ +name: Feature request +description: Suggest a new feature or an improvement +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + One request per issue, please. + - type: checkboxes + id: checks + attributes: + label: Before you start + options: + - label: I searched [existing issues](https://github.com/l0ng-ai/openidea/issues?q=is%3Aissue) and this hasn't been requested yet. + required: true + - type: textarea + id: problem + attributes: + label: Problem + description: What are you trying to do, and what gets in the way today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: How you'd like it to work. Mockups or examples from other tools are welcome. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives and workarounds + description: Other approaches you considered, or what you do now instead. + - type: textarea + id: extra + attributes: + label: Additional context diff --git a/.oxfmtrc.json b/.oxfmtrc.json index d2730f45..70f64132 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -10,6 +10,7 @@ "target", "src/bindings.ts", "src/editor/core/cm/angular/parser*.ts", + "src/editor/core/cm/jsp/parser*.ts", "docs/plugins/themes.md", "pnpm-lock.yaml", "**/*.toml", diff --git a/Cargo.lock b/Cargo.lock index c3ad7874..461692d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "ide-agent" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "encoding_rs", @@ -4061,7 +4061,7 @@ dependencies = [ [[package]] name = "ide-analysis" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ast-grep-core", "ide-core", @@ -4075,22 +4075,22 @@ dependencies = [ [[package]] name = "ide-build" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dirs 6.0.0", "dunce", "ide-core", - "libc", "serde", "serde_json", "specta", "tempfile", "tracing", + "zip", ] [[package]] name = "ide-clipboard" -version = "0.1.33" +version = "0.1.34" dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", @@ -4099,10 +4099,11 @@ dependencies = [ [[package]] name = "ide-core" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "encoding_rs", + "libc", "serde", "serde_json", "specta", @@ -4115,7 +4116,7 @@ dependencies = [ [[package]] name = "ide-coverage" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-run", @@ -4129,7 +4130,7 @@ dependencies = [ [[package]] name = "ide-dap" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-jsonrpc", @@ -4147,7 +4148,7 @@ dependencies = [ [[package]] name = "ide-db" -version = "0.1.33" +version = "0.1.34" dependencies = [ "bb8", "dirs 6.0.0", @@ -4169,10 +4170,9 @@ dependencies = [ [[package]] name = "ide-docker" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", - "libc", "serde", "serde_json", "specta", @@ -4183,7 +4183,7 @@ dependencies = [ [[package]] name = "ide-fonts" -version = "0.1.33" +version = "0.1.34" dependencies = [ "objc2-core-foundation", "objc2-core-text", @@ -4195,11 +4195,10 @@ dependencies = [ [[package]] name = "ide-github" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-vcs", - "libc", "serde", "serde_json", "specta", @@ -4209,7 +4208,7 @@ dependencies = [ [[package]] name = "ide-host" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dirs 6.0.0", "dunce", @@ -4241,6 +4240,7 @@ dependencies = [ "ide-vfs", "ide-workspace", "parking_lot", + "rustix", "serde", "serde_json", "specta", @@ -4250,11 +4250,12 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "windows-sys 0.61.2", ] [[package]] name = "ide-http" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "parking_lot", @@ -4272,7 +4273,7 @@ dependencies = [ [[package]] name = "ide-index" -version = "0.1.33" +version = "0.1.34" dependencies = [ "blake3", "criterion", @@ -4294,7 +4295,7 @@ dependencies = [ [[package]] name = "ide-jsonrpc" -version = "0.1.33" +version = "0.1.34" dependencies = [ "encoding_rs", "futures", @@ -4310,7 +4311,7 @@ dependencies = [ [[package]] name = "ide-language" -version = "0.1.33" +version = "0.1.34" dependencies = [ "blake3", "criterion", @@ -4365,7 +4366,7 @@ dependencies = [ [[package]] name = "ide-lsp" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "futures", @@ -4390,7 +4391,7 @@ dependencies = [ [[package]] name = "ide-notebook" -version = "0.1.33" +version = "0.1.34" dependencies = [ "futures", "ide-core", @@ -4413,7 +4414,7 @@ dependencies = [ [[package]] name = "ide-plugin" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-language", @@ -4427,11 +4428,12 @@ dependencies = [ "tracing", "wasmtime", "wasmtime-wasi", + "zip", ] [[package]] name = "ide-profiler" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-run", @@ -4444,7 +4446,7 @@ dependencies = [ [[package]] name = "ide-remote" -version = "0.1.33" +version = "0.1.34" dependencies = [ "globset", "ide-core", @@ -4461,7 +4463,7 @@ dependencies = [ [[package]] name = "ide-run" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "encoding_rs", @@ -4484,7 +4486,7 @@ dependencies = [ [[package]] name = "ide-sdk" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "ide-core", @@ -4503,7 +4505,7 @@ dependencies = [ [[package]] name = "ide-search" -version = "0.1.33" +version = "0.1.34" dependencies = [ "criterion", "globset", @@ -4523,7 +4525,7 @@ dependencies = [ [[package]] name = "ide-spell" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "parking_lot", @@ -4536,7 +4538,7 @@ dependencies = [ [[package]] name = "ide-ssh" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dirs 6.0.0", "flate2", @@ -4555,7 +4557,7 @@ dependencies = [ [[package]] name = "ide-syntax" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-language", @@ -4570,7 +4572,7 @@ dependencies = [ [[package]] name = "ide-terminal" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "libc", @@ -4583,7 +4585,7 @@ dependencies = [ [[package]] name = "ide-text" -version = "0.1.33" +version = "0.1.34" dependencies = [ "criterion", "ide-core", @@ -4595,7 +4597,7 @@ dependencies = [ [[package]] name = "ide-tools" -version = "0.1.33" +version = "0.1.34" dependencies = [ "flate2", "hex", @@ -4616,7 +4618,7 @@ dependencies = [ [[package]] name = "ide-vcs" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "encoding_rs", @@ -4635,7 +4637,7 @@ dependencies = [ [[package]] name = "ide-vfs" -version = "0.1.33" +version = "0.1.34" dependencies = [ "chardetng", "dunce", @@ -4657,7 +4659,7 @@ dependencies = [ [[package]] name = "ide-web" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "open", @@ -4673,7 +4675,7 @@ dependencies = [ [[package]] name = "ide-workspace" -version = "0.1.33" +version = "0.1.34" dependencies = [ "blake3", "dunce", @@ -4683,6 +4685,7 @@ dependencies = [ "ide-text", "ide-vfs", "parking_lot", + "semver", "serde", "serde_json", "specta", @@ -6144,12 +6147,13 @@ dependencies = [ [[package]] name = "openidea" -version = "0.1.33" +version = "0.1.34" dependencies = [ "blake3", "block2 0.6.2", "dirs 6.0.0", "dunce", + "gtk", "ide-agent", "ide-analysis", "ide-build", @@ -6207,6 +6211,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9da1d526..9906e994 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "src-tauri"] [workspace.package] -version = "0.1.33" +version = "0.1.34" edition = "2024" rust-version = "1.96" license = "Apache-2.0" diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 94751e75..6b09f458 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -2,8 +2,9 @@ ## Project Lombok -`plugins/lombok/lombok-1.18.48-openidea.1.jar` contains Project Lombok v1.18.48 with the Equinox -classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059). +`plugins/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox +classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059) +and the ECJ constructor-call fix from [projectlombok/lombok#4089](https://github.com/projectlombok/lombok/pull/4089). Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component notices are retained in [plugins/lombok/LICENSE](plugins/lombok/LICENSE) and inside the jar. Source tag, patch commits and build instructions: [plugins/lombok/README.md](plugins/lombok/README.md). diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index f2fb12a5..cabe079e 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -314,10 +314,25 @@ impl AgentManager { source: p.source.clone(), }) .collect(); - *self.shared.providers.write() = specs; + let mut prev = self.shared.providers.write(); + let mut probes = self.shared.probes.lock(); + probes.retain(|id, _| { + let old = prev.iter().find(|p| &p.id == id); + let new = specs.iter().find(|p| &p.id == id); + matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env) + }); + *prev = specs; list } + /// Re-reads configured/installed agents, drops cached probes, and notifies subscribers. + pub fn reload_providers(&self) { + let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref()); + *self.shared.providers.write() = specs; + self.shared.probes.lock().clear(); + self.shared.emit(AgentEvent::Providers); + } + /// The slash commands to complete for `provider`, in `task`'s directory or (without one) /// in the project: what the agent offered last time there, unadvertised builtins, /// and, for Claude Code, skills and custom commands on disk it has not listed. @@ -808,7 +823,8 @@ impl AgentManager { // A copy of the task (a fork) may work in the same worktree. let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd); if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() { - blocking(move || worktree::remove(&root, &cwd)).await?; + let worktrees = self.shared.paths.worktrees_dir.clone(); + blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?; } self.tasks.write().remove(id); *task.deleted.lock() = true; diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index 67b2b619..1ba50ae8 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -413,4 +413,6 @@ pub enum AgentEvent { index: u32, entry: AgentEntry, }, + /// The installed or configured agent providers changed. + Providers, } diff --git a/crates/ide-agent/src/native/claude/translate.rs b/crates/ide-agent/src/native/claude/translate.rs index ac8158f0..066be5f0 100644 --- a/crates/ide-agent/src/native/claude/translate.rs +++ b/crates/ide-agent/src/native/claude/translate.rs @@ -169,7 +169,7 @@ impl Translator { Some("tool_use") => { // The input streams after this: shown by name until the message has it. let (id, name) = (str_of(&block, "id"), str_of(&block, "name")); - if !id.is_empty() && !tools::is_plan(&name) && !(tools::is_agent(&name) && self.subagents) { + if !(id.is_empty() || tools::is_plan(&name) || tools::is_agent(&name) && self.subagents) { out.extend(self.ensure_call(&id, &name, &json!({}), &session)); } } diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs index 47902e0f..17608704 100644 --- a/crates/ide-agent/src/provider.rs +++ b/crates/ide-agent/src/provider.rs @@ -80,9 +80,16 @@ fn builtins() -> Vec { .collect() } -/// Whether `id` names a built-in agent. +/// Whether `id` names a built-in agent that needs no registry install (native drivers +/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and +/// updated from the ACP Registry into `/agents/.toml`. pub(crate) fn is_builtin(id: &str) -> bool { - BUILTIN.iter().any(|b| b.0 == id) + BUILTIN.iter().any(|&(bid, _, driver, cmd, _)| bid == id && (driver != Driver::Acp || cmd.first() == Some(&"npx"))) +} + +/// Display name of a built-in agent, when `id` is one. +pub(crate) fn builtin_name(id: &str) -> Option<&'static str> { + BUILTIN.iter().find(|&&(bid, _, _, _, _)| bid == id).map(|&(_, name, _, _, _)| name) } /// An agent file, `/agents/.toml`. @@ -162,7 +169,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec { Some(r) => AgentSource::Registry { version: r.version, update: None }, None => AgentSource::Custom, }; - let spec = ProviderSpec { + let mut spec = ProviderSpec { name: agent.name.unwrap_or_else(|| id.clone()), driver: agent.driver, command: agent.command, @@ -173,7 +180,10 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec { id, }; match specs.iter_mut().find(|s| s.id == spec.id) { - Some(existing) => *existing = spec, + Some(existing) => { + spec.login_hint = existing.login_hint.clone(); + *existing = spec; + } None => specs.push(spec), } } diff --git a/crates/ide-agent/src/registry.rs b/crates/ide-agent/src/registry.rs index 26bf31f3..1c22007d 100644 --- a/crates/ide-agent/src/registry.rs +++ b/crates/ide-agent/src/registry.rs @@ -165,6 +165,13 @@ fn parse(text: &str) -> IdeResult> { .into_iter() .filter_map(|v| serde_json::from_value::(v).ok()) .filter(|e| safe_segment(&e.id) && safe_segment(&e.version)) + .map(|mut e| { + e.id = canonical_id(&e.id).to_owned(); + if let Some(name) = provider::builtin_name(&e.id) { + e.name = name.to_owned(); + } + e + }) .collect()) } @@ -232,11 +239,16 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo None } -/// Registry agents that are built-in ones under another id. -const BUILTIN_ALIASES: &[&str] = &["github-copilot-cli", "qwen-code", "mistral-vibe"]; +/// Registry ids that map to a shorter built-in provider id (`/agents/.toml`). +const BUILTIN_ALIASES: &[(&str, &str)] = + &[("github-copilot-cli", "copilot"), ("qwen-code", "qwen"), ("mistral-vibe", "vibe")]; + +fn canonical_id(id: &str) -> &str { + BUILTIN_ALIASES.iter().find_map(|&(alias, canonical)| (alias == id).then_some(canonical)).unwrap_or(id) +} fn is_builtin(id: &str) -> bool { - provider::is_builtin(id) || BUILTIN_ALIASES.contains(&id) + provider::is_builtin(canonical_id(id)) } fn on_path(command: &str) -> bool { @@ -387,6 +399,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { /// Installs (or updates to) the registry's current version of `id`, streaming progress /// through `emit`, and points the agent file at it. pub fn install(paths: &RegistryPaths, id: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> { + let id = canonical_id(id); // The saved index is what the user picked from; fetch only when there is none. let text = match fs::read_to_string(&paths.cache_file) { Ok(text) => text, @@ -618,6 +631,7 @@ fn install_uv( /// Deletes a registry agent's file and installed files. pub fn remove(paths: &RegistryPaths, id: &str) -> IdeResult<()> { + let id = canonical_id(id); if !safe_segment(id) { return Err(IdeError::invalid(format!("invalid agent id {id}"))); } @@ -946,4 +960,64 @@ mod tests { assert!(remove(&p, "amp-acp").is_err()); assert!(p.agent_file("amp-acp").exists()); } + + #[test] + fn maps_github_copilot_cli_to_builtin_copilot_and_tracks_updates() { + let dir = tempfile::tempdir().unwrap(); + let p = paths(dir.path()); + let index = r#"{"agents":[ + {"id":"github-copilot-cli","name":"Copilot","version":"1.0.105", + "description":"GitHub's AI pair programmer", + "distribution":{"npx":{"package":"@github/copilot@1.0.105","args":["--acp"]}}} + ]}"#; + let entries = parse(index).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!((entries[0].id.as_str(), entries[0].name.as_str()), ("copilot", "GitHub Copilot")); + + let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node"); + assert_eq!(shown.len(), 1); + assert_eq!( + (shown[0].id.as_str(), shown[0].name.as_str(), shown[0].installed.as_deref()), + ("copilot", "GitHub Copilot", None) + ); + + CustomAgent { + name: Some("GitHub Copilot".into()), + driver: Driver::Acp, + command: "/data/tools/copilot/1.0.104/node_modules/.bin/copilot".into(), + args: vec!["--acp".into()], + env: BTreeMap::new(), + registry: Some(RegistryRef { id: "copilot".into(), version: "1.0.104".into() }), + } + .write(&p.agent_file("copilot")) + .unwrap(); + + let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node"); + assert_eq!((shown[0].installed.as_deref(), shown[0].update), (Some("1.0.104"), true)); + + write_atomic(&p.cache_file, index.as_bytes()).unwrap(); + let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap(); + assert_eq!(spec.name, "GitHub Copilot"); + assert_eq!(spec.login_hint, "Run `copilot` in a terminal and sign in."); + assert_eq!(spec.source, AgentSource::Registry { version: "1.0.104".into(), update: None }); + + let mut providers = vec![AgentProvider { + id: spec.id, + name: spec.name, + command: spec.command, + installed: true, + config: vec![], + commands: vec![], + source: spec.source, + }]; + mark_updates(&p, &mut providers); + assert_eq!( + providers[0].source, + AgentSource::Registry { version: "1.0.104".into(), update: Some("1.0.105".into()) } + ); + + remove(&p, "github-copilot-cli").unwrap(); + let fallback = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap(); + assert_eq!(fallback.source, AgentSource::Builtin); + } } diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index b4625197..6bd3ea2b 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -942,3 +942,78 @@ async fn task_project_root_controls_local_and_worktree_repositories() { } assert_eq!(git(&["worktree", "list", "--porcelain"]).matches("worktree ").count(), 1); } + +#[tokio::test(flavor = "multi_thread")] +async fn reload_providers_updates_registry_install_clears_stale_probes_and_notifies_subscribers() { + let tmp = tempfile::tempdir().unwrap(); + let agents_dir = tmp.path().join("agents"); + std::fs::create_dir_all(&agents_dir).unwrap(); + let fake_bin = tmp.path().join(if cfg!(windows) { "copilot.cmd" } else { "copilot" }); + std::fs::write(&fake_bin, "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let agent = Arc::new(FakeAgent::default()); + let connector: Connector = { + let agent = agent.clone(); + Arc::new(move |_spec, _cwd, client, _stderr| { + let (a, b) = duplex(1 << 20); + let (ar, aw) = tokio::io::split(a); + let (br, bw) = tokio::io::split(b); + let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines); + agent.servers.lock().push(server); + Ok(Peer::connect_framed(ar, aw, client, Framing::Lines)) + }) + }; + let mgr = AgentManager::with_connector( + AgentPaths { + project_root: tmp.path().to_path_buf(), + store_dir: tmp.path().join("store"), + worktrees_dir: tmp.path().join("worktrees"), + models_file: tmp.path().join("models.json"), + custom_agents_dir: Some(agents_dir.clone()), + home_dir: None, + }, + tokio::runtime::Handle::current(), + connector, + ); + + // Seed a stale failed probe for copilot before installing its custom TOML. + mgr.shared.probes.lock().insert( + "copilot".into(), + AgentProbe { + provider: "copilot".into(), + name: None, + version: None, + protocol: "ACP".into(), + capabilities: AgentCapabilities::default(), + auth_methods: Vec::new(), + error: Some("copilot --acp was not found on PATH.".into()), + }, + ); + + let events = Arc::new(Mutex::new(Vec::new())); + let events_sink = events.clone(); + mgr.subscribe(Arc::new(move |ev| events_sink.lock().push(ev))); + + let toml = format!( + "name = \"GitHub Copilot\"\ncommand = {:?}\nargs = [\"--acp\", \"--stdio\"]\n", + fake_bin.to_string_lossy() + ); + std::fs::write(agents_dir.join("copilot.toml"), toml).unwrap(); + + mgr.reload_providers(); + + assert!(events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers))); + assert!(!mgr.shared.probes.lock().contains_key("copilot")); + + let copilot = mgr.providers().into_iter().find(|p| p.id == "copilot").unwrap(); + assert!(copilot.installed); + assert!(copilot.command.contains(&*fake_bin.to_string_lossy())); + + let probe = mgr.probe("copilot", false).await.unwrap(); + assert!(probe.error.is_none()); +} diff --git a/crates/ide-agent/src/worktree.rs b/crates/ide-agent/src/worktree.rs index 6b261b3a..b0e2f62f 100644 --- a/crates/ide-agent/src/worktree.rs +++ b/crates/ide-agent/src/worktree.rs @@ -48,8 +48,24 @@ pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, bas Ok(Created { path, branch, base, base_commit }) } -pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> { - repo_at(project_root)?.remove_worktree(path, true) +/// `git worktree remove --force -- `. When git no longer recognizes the +/// worktree (a pruned registration, a moved repository, a deleted `.git` file) +/// it fails with "is not a working tree". A folder under `worktrees_dir` is still +/// ours, so it is removed directly to keep the task deletable (a stale +/// registration prunes later); anything else keeps git's error. +pub(crate) fn remove(project_root: &Path, worktrees_dir: &Path, path: &Path) -> IdeResult<()> { + let repo = repo_at(project_root)?; + let err = match repo.remove_worktree(path, true) { + Ok(()) => return Ok(()), + Err(e) => e, + }; + if !path.exists() { + return Ok(()); + } + if !path.starts_with(worktrees_dir) || path == worktrees_dir { + return Err(err); + } + std::fs::remove_dir_all(path).map_err(|e| IdeError::internal(e.to_string())) } /// Files under `cwd` that differ from `base` (untracked files included), with line counts. @@ -153,7 +169,7 @@ mod tests { assert!(snapshot(&dir).unwrap().is_empty()); assert_eq!(file_at(&dir, &dir.join("a.txt"), "HEAD").unwrap(), None); assert!(create(&dir, &tmp.path().join("wt"), "Edit A", None).is_err()); - assert!(remove(&dir, &dir).is_err()); + assert!(remove(&dir, tmp.path(), &dir).is_err()); } fn git(dir: &Path, args: &[&str]) { @@ -202,7 +218,38 @@ mod tests { // A second task with the same title gets its own worktree. assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2"); - remove(&repo, &created.path).unwrap(); + remove(&repo, &tmp.path().join("wt"), &created.path).unwrap(); assert!(!created.path.exists()); } + + #[test] + fn a_broken_worktree_registration_still_leaves_a_deletable_folder() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + std::fs::write(repo.join("a.txt"), "one\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + + // A pruned registration (the repository moved, `git worktree prune` ran). + let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap(); + let registration = repo.join(".git/worktrees").join(created.path.file_name().unwrap()); + std::fs::remove_dir_all(registration).unwrap(); + remove(&repo, &tmp.path().join("wt"), &created.path).unwrap(); + assert!(!created.path.exists()); + + // A deleted `.git` file in the worktree itself. + let created = create(&repo, &tmp.path().join("wt"), "Edit B", None).unwrap(); + std::fs::remove_file(created.path.join(".git")).unwrap(); + remove(&repo, &tmp.path().join("wt"), &created.path).unwrap(); + assert!(!created.path.exists()); + + // A folder outside the worktrees directory is never deleted by hand. + let elsewhere = tmp.path().join("elsewhere"); + std::fs::create_dir(&elsewhere).unwrap(); + std::fs::write(elsewhere.join("keep.txt"), "x\n").unwrap(); + assert!(remove(&repo, &tmp.path().join("wt"), &elsewhere).is_err()); + assert!(elsewhere.join("keep.txt").exists()); + } } diff --git a/crates/ide-build/Cargo.toml b/crates/ide-build/Cargo.toml index eff2f248..bda630cf 100644 --- a/crates/ide-build/Cargo.toml +++ b/crates/ide-build/Cargo.toml @@ -8,14 +8,12 @@ license.workspace = true [dependencies] dunce.workspace = true ide-core.workspace = true +zip.workspace = true serde.workspace = true serde_json.workspace = true specta.workspace = true tracing.workspace = true dirs.workspace = true -[target.'cfg(unix)'.dependencies] -libc = "0.2" - [dev-dependencies] tempfile.workspace = true diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 9cd0ea26..58aa7241 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -158,8 +158,11 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option PathBuf { - if rel.is_empty() { root.to_path_buf() } else { root.join(rel) } + rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part)) } fn rel_str(root: &Path, dir: &Path) -> String { @@ -185,8 +188,10 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path None => None, }; let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf()); + // A folder of sibling projects has no build file itself; its Maven projects are built one by one. let system = detect_dir(&dir, model, root) .or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None }) + .or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven)) .ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?; let module = module_dir.as_deref().filter(|d| *d != root); let title = match module { @@ -735,11 +740,11 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec vec![module.join("target/test-classes"), module.join("target/classes")], - BuildSystem::Maven => vec![module.join("target/classes")], + BuildSystem::Maven if test => vec![subdir(module, "target/test-classes"), subdir(module, "target/classes")], + BuildSystem::Maven => vec![subdir(module, "target/classes")], _ => Vec::new(), }; dirs.into_iter().filter(|d| d.is_dir()).collect() @@ -749,6 +754,30 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Veccore"); + write(&siblings, "system/pom.xml", "system"); + let model = crate::load(&siblings, &tmp.path().join("cache3"), None); + let p = plan(&siblings, &model, BuildKind::Build, None).unwrap(); + assert_eq!(p.system, BuildSystem::Maven); + assert_eq!( + p.steps.iter().map(|s| s.cwd.clone()).collect::>(), + [siblings.join("core"), siblings.join("system")] + ); + let none = tmp.path().join("none"); std::fs::create_dir_all(&none).unwrap(); assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err()); diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 7f256100..35a60701 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -11,7 +11,9 @@ use crate::xml::{self, Element}; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::Read; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; /// Phases shown under "Lifecycle" (IDEA's default list). pub const LIFECYCLE: &[&str] = @@ -285,6 +287,14 @@ fn texts(e: &Element, path: &str, item: &str) -> Vec { e.list(path, item).iter().map(|m| m.text.trim().to_owned()).filter(|m| !m.is_empty()).collect() } +/// Child modules of a project or profile: `` (model 4.0.0) plus ``, +/// which Maven 4 (model 4.1.0) uses instead. +fn module_names(e: &Element) -> Vec { + let mut names = texts(e, "modules", "module"); + names.extend(texts(e, "subprojects", "subproject")); + names +} + fn read_build(e: &Element, plugins: &mut Vec, managed: &mut Vec) { plugins.extend(e.list("build/plugins", "plugin").into_iter().filter_map(read_plugin)); managed.extend(e.list("build/pluginManagement/plugins", "plugin").into_iter().filter_map(read_plugin)); @@ -308,7 +318,7 @@ fn read_raw(project: &Element) -> RawPom { name: project.text_of("name"), parent, properties: read_properties(project.child("properties")), - modules: texts(project, "modules", "module"), + modules: module_names(project), deps: project.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(), managed: project .list("dependencyManagement/dependencies", "dependency") @@ -329,7 +339,7 @@ fn read_raw(project: &Element) -> RawPom { let mut profile = RawProfile { id: p.text_of("id").unwrap_or_else(|| "default".into()), active_by_default: p.at("activation/activeByDefault").is_some_and(|a| a.text.trim() == "true"), - modules: texts(p, "modules", "module"), + modules: module_names(p), properties: read_properties(p.child("properties")), deps: p.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(), ..RawProfile::default() @@ -448,13 +458,20 @@ struct Resolver { /// `~/.m2/repository`, or `` from `~/.m2/settings.xml`. pub(crate) fn default_local_repo() -> Option { let m2 = dirs::home_dir()?.join(".m2"); - if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t)) - && let Some(repo) = settings.text_of("localRepository") - { - let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default(); - return Some(PathBuf::from(repo.replace("${user.home}", &home))); + local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository"))) +} + +/// `` of a settings file, when it names one: the repository a build +/// with `-s ` resolves into, which is not necessarily the user's default. +pub fn local_repository_of(settings: &Path) -> Option { + let text = std::fs::read_to_string(settings).ok()?; + let root = xml::parse(&text)?; + let repo = root.text_of("localRepository")?.trim().to_owned(); + if repo.is_empty() { + return None; } - Some(m2.join("repository")) + let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default(); + Some(PathBuf::from(repo.replace("${user.home}", &home))) } impl Resolver { @@ -680,7 +697,76 @@ pub fn goal_prefix(artifact_id: &str) -> String { artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned() } -/// Goals of well-known plugins (their plugin descriptors are not read). +/// Goals of every plugin jar read so far, by the path of that jar. +static DESCRIPTOR: OnceLock>>> = OnceLock::new(); + +/// `` names of the plugin descriptor inside its jar (`META-INF/maven/plugin.xml`), +/// which is where Maven itself takes the list `plugin:help` prints. Empty when the jar +/// is not in the local repository yet. +fn descriptor_goals(repo: &Path, group_id: &str, artifact_id: &str, version: &str) -> Vec { + // Keyed by the jar itself, not by its coordinates: a repository can be replaced + // between runs, and a test's empty repository must not answer for another's. + // One `push` per group segment: the layout is `//…` on + // every platform, Windows' separator included. + let mut dir = repo.to_path_buf(); + dir.extend(group_id.split('.')); + dir.push(artifact_id); + dir.push(version); + let Some(jar) = plugin_jar(&dir, artifact_id, version) else { return Vec::new() }; + let key = jar.to_string_lossy().into_owned(); + let cache = DESCRIPTOR.get_or_init(|| Mutex::new(HashMap::new())); + if let Some(cached) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return cached.clone(); + } + // A failed read is not remembered: the jar may be half written by Maven right now. + let Some(goals) = read_descriptor(&jar) else { return Vec::new() }; + cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, goals.clone()); + goals +} + +/// The plugin's jar in its version directory: `-.jar`, or — for a +/// SNAPSHOT installed from a repository, whose file name carries a timestamp — the newest +/// `-*.jar` that is neither sources nor javadoc. +fn plugin_jar(dir: &Path, artifact_id: &str, version: &str) -> Option { + let exact = dir.join(format!("{artifact_id}-{version}.jar")); + if exact.is_file() { + return Some(exact); + } + let prefix = format!("{artifact_id}-"); + let mut best: Option<(std::time::SystemTime, PathBuf)> = None; + for entry in std::fs::read_dir(dir).ok()?.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue }; + if !name.starts_with(&prefix) || !name.ends_with(".jar") || name.ends_with("-sources.jar") { + continue; + } + if name.ends_with("-javadoc.jar") { + continue; + } + let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else { continue }; + if best.as_ref().is_none_or(|(at, _)| modified > *at) { + best = Some((modified, path)); + } + } + best.map(|(_, p)| p) +} + +fn read_descriptor(jar: &Path) -> Option> { + let file = std::fs::File::open(jar).ok()?; + let mut archive = zip::ZipArchive::new(file).ok()?; + let mut entry = archive.by_name("META-INF/maven/plugin.xml").ok()?; + let mut text = String::new(); + entry.read_to_string(&mut text).ok()?; + let root = xml::parse(&text)?; + let mut goals: Vec = + root.list("mojos", "mojo").iter().filter_map(|m| m.text_of("goal")).map(|g| g.trim().to_owned()).collect(); + goals.retain(|g| !g.is_empty()); + goals.sort(); + goals.dedup(); + Some(goals) +} + +/// Goals of well-known plugins, for a jar that is not in the local repository yet. fn known_goals(artifact_id: &str) -> &'static [&'static str] { match artifact_id { "maven-clean-plugin" => &["clean", "help"], @@ -832,9 +918,27 @@ fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] { } } -/// A plugin as shown in the tree: execution goals plus the plugin's known goals, sorted. -pub(crate) fn plugin(group_id: &str, artifact_id: &str, version: Option, goals: &[String]) -> MavenPlugin { +/// A plugin as shown in the tree: the goals its jar declares, the ones known by name for +/// the common plugins, and the ones the project binds in ``, sorted. IDEA lists +/// a plugin's goals the same way, which is why a plugin outside the table (checkstyle, say) +/// still shows every goal it offers instead of only the bound one. +pub(crate) fn plugin( + group_id: &str, + artifact_id: &str, + version: Option, + goals: &[String], + local_repo: Option<&Path>, +) -> MavenPlugin { let mut all: Vec = known_goals(artifact_id).iter().map(|g| (*g).to_owned()).collect(); + let repo = local_repo.map(Path::to_path_buf).or_else(default_local_repo); + // An unresolved `${…}` version names no jar: the table and the bound goals stand in. + if let (Some(repo), Some(version)) = (repo.as_deref(), version.as_deref().filter(|v| !v.contains("${"))) { + for g in descriptor_goals(repo, group_id, artifact_id, version) { + if !all.contains(&g) { + all.push(g); + } + } + } for g in goals { if !all.contains(g) { all.push(g.clone()); @@ -862,13 +966,19 @@ fn rel_path(root: &Path, p: &Path) -> String { } /// The POMs that are not modules of another one: the root `pom.xml`, else the -/// shallowest `pom.xml` of each branch (a folder holding several builds). +/// shallowest `pom.xml` of each branch (a folder holding several builds). An +/// aggregate POM directly inside `src/` counts too (issue #1001: `src/pom.xml` +/// with its modules beside it, `src/app/pom.xml` and so on). fn top_level_poms(root: &Path) -> Vec { let top = root.join("pom.xml"); if top.is_file() { return vec![top]; } let mut out = Vec::new(); + let src_pom = root.join("src").join("pom.xml"); + if src_pom.is_file() { + out.push(src_pom); + } let mut queue: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)]; while let Some((dir, depth)) = queue.pop() { let Ok(entries) = std::fs::read_dir(&dir) else { continue }; @@ -882,9 +992,16 @@ fn top_level_poms(root: &Path) -> Vec { } dirs.sort(); for d in dirs.into_iter().rev() { - if d.join("pom.xml").is_file() { - out.push(d.join("pom.xml")); - } else if depth + 1 < POM_SEARCH_DEPTH { + let pom = d.join("pom.xml"); + if pom.is_file() { + out.push(pom); + continue; + } + let src_pom = d.join("src").join("pom.xml"); + if src_pom.is_file() { + out.push(src_pom); + } + if depth + 1 < POM_SEARCH_DEPTH { queue.push((d, depth + 1)); } } @@ -999,7 +1116,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option) -> MavenM .or_else(|| ctx.managed_plugins.iter().find(|m| m.key() == p.key()).and_then(|m| m.version.clone())) .map(|v| i(&v)) .filter(|v| !v.contains("${")); - plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals) + plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals, resolver.local_repo.as_deref()) }) .collect(); @@ -1111,6 +1228,16 @@ pub fn build_files(root: &Path, model: &MavenModel) -> Vec { /// Projects and their plugins / dependencies from `mvn help:effective-pom` output /// (`` or ``), keyed `groupId:artifactId`. pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec, Vec)> { + parse_effective_pom_in(text, None) +} + +/// The same, with the local repository the project actually builds against (a +/// `-Dmaven.repo.local` of the build, say): the plugin goals come from jars there, +/// rather than from the user's default one. +pub fn parse_effective_pom_in( + text: &str, + local_repo: Option<&Path>, +) -> Vec<(String, Vec, Vec)> { let Some(root) = xml::parse(text) else { return Vec::new() }; let projects: Vec<&Element> = if root.name == "projects" { root.children_named("project").collect() } else { vec![&root] }; @@ -1122,7 +1249,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec, Vec Vec<(String, Vec) mod tests { use super::*; + /// A plugin jar in `repo` whose descriptor declares `goals`. + fn write_plugin_jar(repo: &Path, group: &str, artifact: &str, version: &str, goals: &[&str]) { + let dir = repo.join(group.replace('.', "/")).join(artifact).join(version); + std::fs::create_dir_all(&dir).unwrap(); + let mojos: String = goals.iter().map(|g| format!("{g}")).collect(); + let descriptor = format!("{mojos}"); + let file = std::fs::File::create(dir.join(format!("{artifact}-{version}.jar"))).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap(); + zip.finish().unwrap(); + } + + #[test] + fn a_plugin_lists_the_goals_its_jar_declares() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path(); + write_plugin_jar( + repo, + "org.apache.maven.plugins", + "maven-checkstyle-plugin", + "3.3.1", + &["checkstyle", "checkstyle-aggregate", "help", "check"], + ); + let p = plugin( + "org.apache.maven.plugins", + "maven-checkstyle-plugin", + Some("3.3.1".into()), + &["check".to_owned()], + Some(repo), + ); + assert_eq!(p.goals, ["check", "checkstyle", "checkstyle-aggregate", "help"], "every goal the plugin offers"); + assert_eq!(p.prefix, "checkstyle"); + } + + #[test] + fn a_settings_file_names_the_repository_to_read_goals_from() { + let tmp = tempfile::tempdir().unwrap(); + let settings = tmp.path().join("settings.xml"); + std::fs::write( + &settings, + "${user.home}/repo-from-settings", + ) + .unwrap(); + let repo = local_repository_of(&settings).unwrap(); + assert!(repo.ends_with("repo-from-settings"), "{}", repo.display()); + assert!(!repo.to_string_lossy().contains("${user.home}")); + // A settings file that names none leaves Maven's default alone. + let plain = tmp.path().join("plain.xml"); + std::fs::write(&plain, "true").unwrap(); + assert!(local_repository_of(&plain).is_none()); + assert!(local_repository_of(&tmp.path().join("missing.xml")).is_none()); + } + + #[test] + fn effective_pom_goals_come_from_the_local_repository_it_is_given() { + let tmp = tempfile::tempdir().unwrap(); + write_plugin_jar(tmp.path(), "org.apache.maven.plugins", "maven-demo-plugin", "1.0", &["run", "help"]); + let text = "ga\ + org.apache.maven.pluginsmaven-demo-plugin\ + 1.0run\ + "; + let with_repo = parse_effective_pom_in(text, Some(tmp.path())); + assert_eq!(with_repo[0].1[0].goals, ["help", "run"], "the jar's own goals come first"); + // Without that repository the plugin is unknown: only what the project binds. + assert_eq!(parse_effective_pom(text)[0].1[0].goals, ["run"]); + } + + #[test] + fn a_snapshot_plugin_is_found_by_its_timestamped_jar() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("g/p/maven-demo-plugin/1.0-SNAPSHOT"); + std::fs::create_dir_all(&dir).unwrap(); + let descriptor = "run"; + let file = std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3.jar")).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap(); + zip.finish().unwrap(); + // A sibling sources jar must not win. + std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3-sources.jar")).unwrap(); + let p = plugin("g.p", "maven-demo-plugin", Some("1.0-SNAPSHOT".into()), &[], Some(tmp.path())); + assert_eq!(p.goals, ["run"]); + } + + #[test] + fn a_plugin_without_its_jar_keeps_the_table_and_the_bound_goals() { + let tmp = tempfile::tempdir().unwrap(); + let p = plugin( + "org.apache.maven.plugins", + "maven-checkstyle-plugin", + Some("3.3.1".into()), + &["check".to_owned()], + Some(tmp.path()), + ); + assert_eq!(p.goals, ["check"], "nothing beyond what the project binds"); + let known = + plugin("org.apache.maven.plugins", "maven-compiler-plugin", Some("3.13.0".into()), &[], Some(tmp.path())); + assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table"); + } + fn write(dir: &Path, rel: &str, text: &str) { let p = dir.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); @@ -1276,6 +1504,26 @@ mod tests { "#; + #[test] + fn maven_4_subprojects_are_modules() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "pom.xml", + r#"4.1.0 + demoparent1.0pom + core + extraextra + "#, + ); + write(root, "core/pom.xml", "core"); + write(root, "extra/pom.xml", "extra"); + let model = load_with_repo(root, None); + assert_eq!(model.projects[0].modules, ["core/pom.xml"]); + assert!(model.projects.iter().any(|p| p.pom == "core/pom.xml" && p.aggregated)); + } + #[test] fn multi_module_parent_properties_and_managed_versions() { let tmp = tempfile::tempdir().unwrap(); @@ -1471,6 +1719,25 @@ mod tests { assert!(!is_maven_project(&root.join("c"))); } + #[test] + fn aggregate_pom_in_src_is_a_build() { + // Issue #1001: `src/pom.xml` aggregating modules under `src/` is recognized, + // with its modules loaded — while `src` stays unsearched otherwise. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "src/pom.xml", + "gparentpom\ + app", + ); + write(root, "src/app/pom.xml", "gapp"); + let model = load_with_repo(root, None); + let names: Vec<&str> = model.projects.iter().map(|p| p.artifact_id.as_str()).collect(); + assert_eq!(names, ["parent", "app"]); + assert!(is_maven_project(root)); + } + #[test] fn property_interpolation() { let props: HashMap = diff --git a/crates/ide-build/src/process.rs b/crates/ide-build/src/process.rs index 8177089f..db169b4c 100644 --- a/crates/ide-build/src/process.rs +++ b/crates/ide-build/src/process.rs @@ -2,11 +2,12 @@ //! line to a callback, and cancellation kills the whole process group (the //! `mvnw` / `gradlew` wrapper scripts start the JVM as a child). +use ide_core::process::kill_tree; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::collections::BTreeMap; use std::io::{BufRead, BufReader, Read}; use std::path::Path; -use std::process::{Child, Command, Stdio}; +use std::process::{Command, Stdio}; use std::sync::mpsc; use std::time::Duration; @@ -22,18 +23,6 @@ pub(crate) struct Output { pub text: String, } -fn kill_tree(child: &mut Child) { - #[cfg(unix)] - if let Ok(pid) = i32::try_from(child.id()) { - // SAFETY: plain syscall; the group was created by `process_group(0)` at spawn. - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - let _ = child.kill(); - let _ = child.wait(); -} - /// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's /// search path — the login shell's `PATH` plus version-manager shims — since the /// app's own `PATH` can be minimal and `Command` would not see `mvn` there. @@ -80,12 +69,8 @@ pub(crate) fn run_with_env( let mut command = Command::new(&program); command.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null()); command.stdout(Stdio::piped()).stderr(Stdio::piped()); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - command.process_group(0); - } - ide_core::process::prepare(&mut command); + // Its own group: `mvnw` / `gradlew` start the JVM as a child, and cancelling kills both. + ide_core::process::prepare(ide_core::process::own_group(&mut command)); if clear_env { command.env_clear().envs(env).envs(encoding.env); } diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index cbeed656..815c4631 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -48,6 +48,11 @@ struct CachedMavenProject { #[derive(Clone, Debug, Default, Serialize, Deserialize)] struct MavenCache { + /// Bumped whenever what the cache holds stops matching what the model would be + /// built as now (plugin goals read from a descriptor, say): an older cache is + /// dropped instead of shadowing the model. + #[serde(default)] + version: u32, hash: String, at_ms: f64, error: Option, @@ -66,6 +71,7 @@ struct GradleCache { } const MAVEN_CACHE: &str = "maven.json"; +const MAVEN_CACHE_VERSION: u32 = 1; /// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven /// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2. @@ -121,6 +127,9 @@ fn now_ms() -> f64 { /// Merges the cached effective build into `model`. pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) { let Some(cache) = read_cache::(cache_dir, MAVEN_CACHE) else { return }; + if cache.version != MAVEN_CACHE_VERSION { + return; // built by an older IDE: what it holds no longer matches the model + } model.synced_at = Some(cache.at_ms); model.sync_error = cache.error; let changed = cache.hash != hash_files(&maven::build_files(root, model)); @@ -211,8 +220,31 @@ pub fn sync_maven( let mut model = maven::load(root); maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model); std::fs::create_dir_all(cache_dir)?; - let mut cache = - MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() }; + let mut cache = MavenCache { + version: MAVEN_CACHE_VERSION, + hash: hash_files(&maven::build_files(root, &model)), + verbose: true, + ..MavenCache::default() + }; + // Where the build resolves its jars, and therefore where the plugin goals are read + // from: `-Dmaven.repo.local=` first, then `` of the `-s` settings, + // then the user's default repository (which `plugin()` resolves itself). + let local_repo: Option = + options.maven_options.iter().find_map(|a| a.strip_prefix("-Dmaven.repo.local=")).map(PathBuf::from).or_else( + || { + let mut args = options.maven_options.iter(); + while let Some(arg) = args.next() { + let path = match arg.as_str() { + "-s" | "--settings" => args.next().map(String::as_str), + _ => arg.strip_prefix("-s=").or_else(|| arg.strip_prefix("--settings=")), + }; + if let Some(path) = path { + return maven::local_repository_of(Path::new(path)); + } + } + None + }, + ); let mut errors: Vec = Vec::new(); for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() { let effective = cache_dir.join(format!("effective-pom-{n}.xml")); @@ -241,8 +273,9 @@ pub fn sync_maven( }); } })?; - let effective_projects = - std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).unwrap_or_default(); + let effective_projects = std::fs::read_to_string(&effective) + .map(|t| maven::parse_effective_pom_in(&t, local_repo.as_deref())) + .unwrap_or_default(); let trees = maven::parse_dependency_tree(&out.text); for (key, plugins, deps) in effective_projects { let dependencies = trees.iter().find(|(k, _)| *k == key).map(|(_, t)| t.clone()).unwrap_or(deps); @@ -332,6 +365,7 @@ mod tests { let mut model = maven::load_with_repo(&root, None); assert!(model.synced_at.is_none()); let cache = MavenCache { + version: MAVEN_CACHE_VERSION, hash: hash_files(&maven::build_files(&root, &model)), at_ms: 5.0, error: None, @@ -343,6 +377,7 @@ mod tests { "maven-clean-plugin", Some("3.2.0".into()), &[], + None, )], dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }], }], @@ -368,6 +403,41 @@ mod tests { assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh"); } + #[test] + fn a_cache_from_an_older_ide_is_ignored() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("p"); + let cache_dir = tmp.path().join("cache"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("pom.xml"), "ga") + .unwrap(); + let mut model = maven::load_with_repo(&root, None); + let cache = MavenCache { + // What an older build wrote: no plugin descriptors were read back then, so + // its goal lists would shadow the ones the model now knows. + version: 0, + hash: hash_files(&maven::build_files(&root, &model)), + at_ms: 5.0, + error: None, + verbose: true, + projects: vec![CachedMavenProject { + key: "g:a".into(), + plugins: vec![maven::plugin( + "org.apache.maven.plugins", + "maven-clean-plugin", + Some("3.2.0".into()), + &[], + None, + )], + dependencies: vec![MavenDependency { artifact_id: "cached".into(), ..MavenDependency::default() }], + }], + }; + write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap(); + apply_maven_cache(&root, &cache_dir, &mut model); + assert!(model.synced_at.is_none(), "the cache is dropped, the model stays unsynced"); + assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached")); + } + #[test] fn gradle_cache_replaces_tasks_per_project() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-core/Cargo.toml b/crates/ide-core/Cargo.toml index a2a26d94..4f9b43a6 100644 --- a/crates/ide-core/Cargo.toml +++ b/crates/ide-core/Cargo.toml @@ -18,5 +18,8 @@ tokio-util.workspace = true [dev-dependencies] tempfile.workspace = true +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] } diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 5de98978..6cef110c 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -36,12 +36,21 @@ pub enum IdeEvent { base_version: u32, version: u32, changes: Vec, + /// Part of a grouped transaction; the editor must not record a file-local undo. + #[serde(default)] + #[specta(optional)] + workspace_edit: bool, }, /// A document's dirty flag changed (saved / modified externally). DocDirty { doc: DocId, dirty: bool, }, + /// A complete workspace edit can be undone as one operation. + WorkspaceEditApplied { + id: u32, + paths: Vec, + }, /// An open document's language changed (Settings › File Types, Override File /// Type), or its UI mode (a framework's root marker appeared or went). DocLanguage { @@ -82,6 +91,12 @@ pub enum IdeEvent { status: ServerStatus, message: Option, }, + /// A running server lacks a capability; the UI offers the managed tool as a replacement. + LanguageServerWarning { + name: String, + message: String, + install_tool: String, + }, /// A stopped language server left the project's list (disabled in settings, or a /// fallback that a preferred server took over from again). LanguageServerRemoved { diff --git a/crates/ide-core/src/java_runtime.rs b/crates/ide-core/src/java_runtime.rs new file mode 100644 index 00000000..83d1ee21 --- /dev/null +++ b/crates/ide-core/src/java_runtime.rs @@ -0,0 +1,25 @@ +//! Capabilities needed by the JVM hosting Java language-server bundles. + +use std::path::Path; + +/// jdtls' javac bundle, java-debug / hot code replace, and Lombok / Gradle tooling. +const BUNDLE_MODULES: &[&str] = &["jdk.compiler", "jdk.jdi", "jdk.unsupported"]; + +/// Require a full JDK, including the modules used by jdtls and its bundles. +/// Temurin JREs can list the build JDK's modules in `release` even when the runtime +/// image omits them, so module metadata alone does not establish suitability. +pub fn supports_java_bundles(home: &Path) -> bool { + let Ok(release) = std::fs::read_to_string(home.join("release")) else { return false }; + let property = + |key: &str| release.lines().find_map(|line| line.trim().strip_prefix(key)).map(|v| v.trim().trim_matches('"')); + let javac = if cfg!(windows) { "javac.exe" } else { "javac" }; + if property("IMAGE_TYPE=").is_some_and(|image| image.eq_ignore_ascii_case("jre")) + || !home.join("bin").join(javac).is_file() + { + return false; + } + match property("MODULES=") { + Some(modules) => BUNDLE_MODULES.iter().all(|required| modules.split_whitespace().any(|m| m == *required)), + None => BUNDLE_MODULES.iter().all(|m| home.join("jmods").join(format!("{m}.jmod")).is_file()), + } +} diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index fcb39a1f..023f9d72 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod ids; pub mod ignored; pub mod inspection_settings; pub mod ipc_path; +pub mod java_runtime; pub mod jsonc; pub mod output_encoding; pub mod process; diff --git a/crates/ide-core/src/output_encoding.rs b/crates/ide-core/src/output_encoding.rs index f5e85f3d..de53b540 100644 --- a/crates/ide-core/src/output_encoding.rs +++ b/crates/ide-core/src/output_encoding.rs @@ -215,13 +215,21 @@ fn fix_kind( } /// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding` -/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself. -pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option { +/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only +/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`]. +pub fn java_debug_vm_args( + vm_args: &str, + vars: &BTreeMap, + inherited: impl Fn(&str) -> Option, + encoding: Option<&str>, + windows: bool, +) -> Option { let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8"); - if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) { + let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args; + if added.is_empty() { return None; } - let added = options(STD_PROPERTIES, encoding).join(" "); + let added = added.join(" "); Some(match vm_args.trim() { "" => added, own => format!("{own} {added}"), @@ -446,12 +454,53 @@ mod tests { #[test] fn java_debug_launches_write_what_java_debug_decodes() { - assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8)); - assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}"))); - assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK")); - assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None); - assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None); - assert!(java_debug_vm_args("", Some("GBK"), false).is_some()); + let debug = + |args: &str, encoding, windows| java_debug_vm_args(args, &BTreeMap::new(), |_| None, encoding, windows); + for encoding in [None, Some(""), Some(" "), Some("UTF-8"), Some("utf8")] { + let streams = encoding.filter(|e| !e.trim().is_empty()).unwrap_or("UTF-8"); + let utf8 = format!("{} -Dfile.encoding=UTF-8", options(STD_PROPERTIES, streams).join(" ")); + assert_eq!(debug("", encoding, true), Some(utf8.clone())); + assert_eq!(debug("-Xmx1g", encoding, true), Some(format!("-Xmx1g {utf8}"))); + assert_eq!(debug("-Xmx1g", encoding, false), None); + } + for windows in [true, false] { + assert_eq!(debug("", Some("GBK"), windows), Some(options(STD_PROPERTIES, "GBK").join(" "))); + } + } + + #[test] + fn java_debug_preserves_user_options_independently() { + let debug = |args: &str| java_debug_vm_args(args, &BTreeMap::new(), |_| None, None, true); + assert_eq!(debug("-Dfile.encoding=GBK"), Some(format!("-Dfile.encoding=GBK {TOOL_UTF8}"))); + for property in STD_PROPERTIES { + let args = format!("-D{property}=GBK"); + assert_eq!(debug(&args), Some(format!("{args} -Dfile.encoding=UTF-8"))); + assert_eq!(debug(&format!("{args} -Dfile.encoding=GBK")), None); + } + for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] { + for (own, expected) in [ + ("-Dfile.encoding=GBK", Some(TOOL_UTF8.to_owned())), + ("-Dstdout.encoding=GBK", Some("-Dfile.encoding=UTF-8".to_owned())), + ("-Dstdout.encoding=GBK -Dfile.encoding=GBK", None), + ] { + assert_eq!(java_debug_vm_args("", &vars(&[(key, own)]), |_| None, None, true), expected); + assert_eq!( + java_debug_vm_args("", &BTreeMap::new(), |k| (k == key).then(|| own.to_owned()), None, true), + expected + ); + } + // A launch environment value overrides the inherited one, as in Run. + assert_eq!( + java_debug_vm_args( + "", + &vars(&[(key, "")]), + |k| (k == key).then(|| "-Dfile.encoding=GBK".into()), + None, + true + ), + Some(format!("{TOOL_UTF8} -Dfile.encoding=UTF-8")) + ); + } } fn filtered(chunks: &[&str]) -> String { diff --git a/crates/ide-core/src/process.rs b/crates/ide-core/src/process.rs index 85ee20f5..b7a74e44 100644 --- a/crates/ide-core/src/process.rs +++ b/crates/ide-core/src/process.rs @@ -1,8 +1,11 @@ //! Child-process helpers shared by every crate that spawns tools. use std::ffi::{OsStr, OsString}; -use std::process::Command; +#[cfg(windows)] +use std::process::Stdio; +use std::process::{Child, Command}; use std::sync::{Arc, RwLock}; +use std::time::Duration; /// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it /// with other flags (and then call [`host_env`] themselves). Prefer [`prepare`] otherwise. @@ -25,6 +28,70 @@ pub fn prepare(cmd: &mut Command) -> &mut Command { host_env(cmd) } +/// Starts the child as the leader of its own process group on Unix, so [`kill_tree`] and +/// [`terminate_tree`] reach what it starts in turn (`gh` → `git`, `npm` → `node`, `mvnw` → +/// the JVM). A no-op elsewhere: on Windows the tree is found from the child's process id. +pub fn own_group(cmd: &mut Command) -> &mut Command { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + cmd +} + +/// Kills the child and everything it started, then reaps it. +/// +/// - Unix: `SIGKILL` to its process group, so spawn it with [`own_group`]. +/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id. +pub fn kill_tree(child: &mut Child) { + #[cfg(unix)] + signal_group(child, libc::SIGKILL); + #[cfg(windows)] + taskkill_tree(child); + let _ = child.kill(); + let _ = child.wait(); +} + +/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`. +/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next +/// git command fails. Windows has no such signal to send from outside the child's console, +/// so there it is [`kill_tree`]. +pub fn terminate_tree(child: &mut Child, grace: Duration) { + #[cfg(unix)] + { + signal_group(child, libc::SIGTERM); + // Sleep rather than `try_wait`: the child stays unreaped, so its process id (and the + // group id) cannot be reused before the `SIGKILL` below. + std::thread::sleep(grace); + } + #[cfg(not(unix))] + let _ = grace; + kill_tree(child); +} + +#[cfg(unix)] +fn signal_group(child: &Child, signal: libc::c_int) { + if let Ok(pid) = libc::pid_t::try_from(child.id()) { + // SAFETY: plain syscall. The child is not reaped yet, so `-pid` is still its group (or + // no group at all when it was not spawned with `own_group`, and the call fails). + unsafe { + libc::kill(-pid, signal); + } + } +} + +#[cfg(windows)] +fn taskkill_tree(child: &Child) { + let mut taskkill = Command::new("taskkill"); + prepare(&mut taskkill) + .args(["/T", "/F", "/PID", &child.id().to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let _ = taskkill.status(); +} + /// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and /// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`, /// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that @@ -245,10 +312,7 @@ pub fn find_executable(name: &str, search: &OsStr) -> Option #[cfg(windows)] pub fn user_path() -> Option { use windows_sys::Win32::Foundation::ERROR_SUCCESS; - use windows_sys::Win32::System::Registry::{ - HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW, - RegOpenKeyExW, - }; + use windows_sys::Win32::System::Registry::{HKEY, HKEY_CURRENT_USER, KEY_READ, RegCloseKey, RegOpenKeyExW}; fn wide(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() @@ -259,45 +323,53 @@ pub fn user_path() -> Option { if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS { return None; } - let read = || { - // Only `Path`; `REG_EXPAND_SZ` is accepted so `%USERPROFILE%\…` entries expand. - let mut kind = 0u32; - let mut len = 0u32; - let name = wide("Path"); - let status = RegGetValueW( - key, - std::ptr::null(), - name.as_ptr(), - RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, - &mut kind, - std::ptr::null_mut(), - &mut len, - ); - if status != ERROR_SUCCESS || len == 0 { - return None; - } - let mut buf = vec![0u16; (len as usize).div_ceil(2)]; - let status = RegGetValueW( - key, - std::ptr::null(), - name.as_ptr(), - RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, - &mut kind, - buf.as_mut_ptr() as *mut _, - &mut len, - ); - if status != ERROR_SUCCESS { - return None; - } - let text = String::from_utf16_lossy(&buf); - Some(text.trim_end_matches('\0').to_owned()) - }; - let value = read(); + let value = read_registry_string(key, &wide("Path")); RegCloseKey(key); value.filter(|v| !v.trim().is_empty()).map(OsString::from) } } +/// The string of value `name` under open `key`: `REG_SZ` as-is, `REG_EXPAND_SZ` expanded +/// against the current environment. `None` when the value is missing or unreadable. +/// +/// A `REG_EXPAND_SZ` value's first size query reports the unexpanded size, so a value +/// that grows when expanded is re-read at the size the failed call reports. And the +/// terminating NUL can leak into the read buffer with a stray UTF-16 unit behind it — +/// seen with a `REG_EXPAND_SZ` user `Path`, where it made `env::set_var("PATH", …)` +/// panic with "strings passed to WinAPI cannot contain NULs" — so the text is cut at +/// the first NUL, not just stripped of trailing ones. +#[cfg(windows)] +fn read_registry_string(key: windows_sys::Win32::System::Registry::HKEY, name: &[u16]) -> Option { + use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS}; + use windows_sys::Win32::System::Registry::{RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegGetValueW}; + + const FLAGS: u32 = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ; + let mut kind = 0u32; + let mut len = 0u32; + // SAFETY: `name` is NUL-terminated and the size query writes only `kind` and `len`. + let status = + unsafe { RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, std::ptr::null_mut(), &mut len) }; + if status != ERROR_SUCCESS || len == 0 { + return None; + } + let buf = loop { + let mut buf = vec![0u16; len as usize / 2 + 1]; + // SAFETY: as above; the buffer holds at least the `len` bytes the call may write. + let status = unsafe { + RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, buf.as_mut_ptr() as *mut _, &mut len) + }; + if status == ERROR_SUCCESS { + break buf; + } + if status != ERROR_MORE_DATA || len == 0 { + return None; + } + }; + let text = String::from_utf16_lossy(&buf); + let end = text.find('\0').unwrap_or(text.len()); + Some(text[..end].to_owned()) +} + /// The user's own `PATH` entries; `None` outside Windows (see the Windows impl). #[cfg(not(windows))] pub fn user_path() -> Option { @@ -334,6 +406,96 @@ mod tests { } } + #[cfg(windows)] + #[test] + fn registry_strings_expand_and_stay_terminator_free() { + use windows_sys::Win32::Foundation::ERROR_SUCCESS; + use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ, REG_SZ, RegCloseKey, RegCreateKeyExW, + RegDeleteTreeW, RegSetValueExW, + }; + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + struct TestState { + key: HKEY, + name: String, + variable: String, + previous: Option, + } + impl Drop for TestState { + fn drop(&mut self) { + // SAFETY: the key belongs to this test; Windows environment mutation is safe. + unsafe { + RegCloseKey(self.key); + RegDeleteTreeW(HKEY_CURRENT_USER, wide(&self.name).as_ptr()); + match &self.previous { + Some(value) => std::env::set_var(&self.variable, value), + None => std::env::remove_var(&self.variable), + } + } + } + } + let name = format!("Software\\ai.l0ng.openidea.registry-read-test-{}", std::process::id()); + let variable = format!("OPENIDEA_TEST_REGVAR_{}", std::process::id()); + let long = "x".repeat(600); + let mut key: HKEY = std::ptr::null_mut(); + let opened = unsafe { + RegCreateKeyExW( + HKEY_CURRENT_USER, + wide(&name).as_ptr(), + 0, + std::ptr::null(), + 0, + KEY_READ | KEY_WRITE, + std::ptr::null(), + &mut key, + std::ptr::null_mut(), + ) + }; + assert_eq!(opened, ERROR_SUCCESS, "the test key must be creatable under HKCU\\Software"); + let _cleanup = TestState { key, name, previous: std::env::var_os(&variable), variable: variable.clone() }; + // SAFETY: Windows permits environment mutation. No other test uses this process-specific name. + unsafe { std::env::set_var(&variable, &long) }; + let set = |value: &str, kind: u32, data: &[u16]| { + // SAFETY: `key` is open for writing and `data` holds `data.len() * 2` bytes. + assert_eq!( + unsafe { + RegSetValueExW( + key, + wide(value).as_ptr(), + 0, + kind, + data.as_ptr() as *const _, + (data.len() * 2) as u32, + ) + }, + ERROR_SUCCESS + ); + }; + let read = |value: &str| read_registry_string(key, &wide(value)); + set("Plain", REG_SZ, &wide("plain")); + set("Expand", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin"))); + set("ExpandLong", REG_EXPAND_SZ, &wide(&format!("%{variable}%"))); + // Reproduce the reported interior terminator followed by a stale UTF-16 unit. + set("TrailingGarbage", REG_SZ, &wide("C:\\Zed\\bin\0n")); + set("ExpandTrailingGarbage", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin\0n"))); + set("Empty", REG_SZ, &wide("")); + let plain = read("Plain"); + let expand = read("Expand"); + let expand_long = read("ExpandLong"); + assert_eq!(plain.as_deref(), Some("plain")); + assert_eq!(expand.as_deref(), Some(format!("{long}\\bin")).as_deref()); + // Longer than its unexpanded form: needs the re-read at the expanded size. + assert_eq!(expand_long.as_deref(), Some(long.as_str())); + assert_eq!(read("TrailingGarbage").as_deref(), Some("C:\\Zed\\bin")); + assert_eq!(read("ExpandTrailingGarbage").as_deref(), Some(format!("{long}\\bin")).as_deref()); + assert_eq!(read("Empty").as_deref(), Some("")); + assert_eq!(read("Missing"), None); + } + #[test] fn append_path_entries_keeps_the_order_and_adds_what_is_missing() { let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap(); @@ -502,3 +664,71 @@ mod tests { assert_eq!(find_executable("nope", &search), None); } } + +#[cfg(all(test, unix))] +mod kill_tests { + use super::*; + use std::path::Path; + use std::time::Instant; + + /// Runs `script` in `sh` as its own group's leader and waits for it to write `ready`. + fn start(script: &str, ready: &Path) -> Child { + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(script); + let child = own_group(&mut cmd).spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !ready.exists() { + assert!(Instant::now() < deadline, "the script never got ready"); + std::thread::sleep(Duration::from_millis(10)); + } + child + } + + fn assert_gone(pid: libc::pid_t) { + let deadline = Instant::now() + Duration::from_secs(5); + // SAFETY: signal 0 only checks that the process exists. + while unsafe { libc::kill(pid, 0) } == 0 { + assert!(Instant::now() < deadline, "process {pid} survived"); + std::thread::sleep(Duration::from_millis(20)); + } + } + + #[test] + fn kill_tree_kills_what_the_child_started() { + let dir = tempfile::tempdir().unwrap(); + let pid_file = dir.path().join("pid"); + let script = format!("sleep 30 & echo $! > '{0}.tmp' && mv '{0}.tmp' '{0}'; wait", pid_file.display()); + let mut child = start(&script, &pid_file); + let grandchild: libc::pid_t = std::fs::read_to_string(&pid_file).unwrap().trim().parse().unwrap(); + kill_tree(&mut child); + assert!(child.try_wait().unwrap().is_some(), "the child is reaped"); + assert_gone(grandchild); + } + + #[test] + fn terminate_tree_lets_the_child_clean_up() { + let dir = tempfile::tempdir().unwrap(); + let (ready, cleaned) = (dir.path().join("ready"), dir.path().join("cleaned")); + let script = format!( + "trap 'touch \"{}\"; exit 0' TERM; touch \"{}\"; sleep 30 & wait", + cleaned.display(), + ready.display() + ); + let mut child = start(&script, &ready); + terminate_tree(&mut child, Duration::from_millis(500)); + assert!(cleaned.exists(), "the SIGTERM handler ran before the SIGKILL"); + assert!(child.try_wait().unwrap().is_some(), "the child is reaped"); + } + + #[test] + fn terminate_tree_still_kills_a_child_that_ignores_sigterm() { + let dir = tempfile::tempdir().unwrap(); + let ready = dir.path().join("ready"); + let script = format!("trap '' TERM; touch \"{}\"; while :; do sleep 1; done", ready.display()); + let mut child = start(&script, &ready); + let started = Instant::now(); + terminate_tree(&mut child, Duration::from_millis(50)); + assert!(child.try_wait().unwrap().is_some(), "the child is reaped"); + assert!(started.elapsed() < Duration::from_secs(5)); + } +} diff --git a/crates/ide-core/src/proxy_settings.rs b/crates/ide-core/src/proxy_settings.rs index 564c2df9..ebae548d 100644 --- a/crates/ide-core/src/proxy_settings.rs +++ b/crates/ide-core/src/proxy_settings.rs @@ -207,6 +207,58 @@ impl ProxySettings { env.push((OsString::from("no_proxy"), Some(OsString::from(no_proxy)))); env } + + /// System properties that make a JVM follow this configuration. A JVM reads none of + /// [`Self::child_env`]: a Java tool (jetty, Maven's resolver, a language server's own + /// HTTP client) goes direct without these, and a repository its proxy answers in + /// milliseconds can then hang it instead. + pub fn jvm_args(&self) -> Vec { + match self.mode { + // macOS and Windows JVMs read the system proxy with this one. + ProxyMode::Auto => vec!["-Djava.net.useSystemProxies=true".into()], + ProxyMode::None => Vec::new(), + ProxyMode::Manual if self.manual() => self.manual_jvm_args(), + ProxyMode::Manual => Vec::new(), + } + } + + fn manual_jvm_args(&self) -> Vec { + let (host, port) = (self.host.trim(), self.port); + match self.kind { + ProxyKind::Http => { + // HTTPS hosts read `https.proxyHost`, plain ones `http.proxyHost`. + let mut args = vec![ + format!("-Dhttp.proxyHost={host}"), + format!("-Dhttp.proxyPort={port}"), + format!("-Dhttps.proxyHost={host}"), + format!("-Dhttps.proxyPort={port}"), + ]; + let hosts = self.non_proxy_hosts(); + if !hosts.is_empty() { + args.push(format!("-Dhttp.nonProxyHosts={}", hosts.join("|"))); + } + args + } + // SOCKS proxies every TCP connection, so there is one property pair. + ProxyKind::Socks => vec![ + format!("-DsocksProxyHost={host}"), + format!("-DsocksProxyPort={port}"), + "-Djava.net.useSystemProxies=false".into(), + ], + } + } + + /// [`Self::no_proxy`] in the JVM's `http.nonProxyHosts` format: `|` between host + /// globs. CIDR ranges and IPv6 literals have no equivalent there and are dropped. + fn non_proxy_hosts(&self) -> Vec { + let mut hosts: Vec = LOOPBACK.iter().filter(|h| !h.contains(':')).map(|h| (*h).to_owned()).collect(); + for entry in self.no_proxy_entries() { + if !entry.contains(['/', ':', '[', ']']) && !hosts.contains(&entry) { + hosts.push(entry); + } + } + hosts + } } /// Case-insensitive host glob (`pattern` is lowercase): `*` matches any run of characters. @@ -292,6 +344,34 @@ mod tests { assert!(p.child_env().is_empty()); } + #[test] + fn jvm_args_carry_the_proxy_as_system_properties() { + // A JVM reads none of `child_env`: a Java language server needs these to not go + // direct while curl, git and Node follow the environment. + assert_eq!(ProxySettings::default().jvm_args(), ["-Djava.net.useSystemProxies=true"]); + assert!(ProxySettings { mode: ProxyMode::None, ..manual("proxy", 3128) }.jvm_args().is_empty()); + assert_eq!( + manual("proxy.corp", 3128).jvm_args(), + [ + "-Dhttp.proxyHost=proxy.corp", + "-Dhttp.proxyPort=3128", + "-Dhttps.proxyHost=proxy.corp", + "-Dhttps.proxyPort=3128", + "-Dhttp.nonProxyHosts=localhost|127.0.0.1", + ] + ); + let socks = ProxySettings { kind: ProxyKind::Socks, ..manual("10.0.0.1", 1080) }; + assert_eq!(socks.jvm_args()[..2], ["-DsocksProxyHost=10.0.0.1", "-DsocksProxyPort=1080"]); + // `http.nonProxyHosts` takes host globs: CIDR ranges and IPv6 literals drop out. + let p = ProxySettings { + no_proxy: "*.corp.com, 172.0.0.0/8, [::1], ::1, localhost".into(), + ..manual("proxy.corp", 3128) + }; + assert_eq!(p.jvm_args().last().unwrap(), "-Dhttp.nonProxyHosts=localhost|127.0.0.1|*.corp.com"); + // A manual proxy without a host connects directly, and so does its JVM. + assert!(manual(" ", 80).jvm_args().is_empty()); + } + #[test] fn manual_http_and_socks_urls() { let p = manual("proxy.corp", 3128); diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 7f360b1f..38b676ca 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -58,6 +58,43 @@ pub struct Settings { pub deployment: DeploymentSettings, /// Build, Execution, Deployment › Run Targets. pub run_targets: Vec, + pub cmake: CMakeSettings, +} + +/// Absent profiles retain automatic discovery for existing projects. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct CMakeSettings { + #[serde(skip_serializing_if = "Option::is_none")] + #[specta(optional)] + pub profiles: Option>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct CMakeProfile { + pub name: String, + pub build_type: String, + pub generator: String, + pub cmake_args: Vec, + /// Empty uses cmake-build-. + pub build_dir: String, + pub build_args: Vec, + pub env: BTreeMap, +} + +impl Default for CMakeProfile { + fn default() -> Self { + Self { + name: "Debug".into(), + build_type: "Debug".into(), + generator: String::new(), + cmake_args: Vec::new(), + build_dir: String::new(), + build_args: Vec::new(), + env: BTreeMap::new(), + } + } } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -154,6 +191,7 @@ impl Default for Settings { ssh: SshSettings::default(), deployment: DeploymentSettings::default(), run_targets: Vec::new(), + cmake: CMakeSettings::default(), } } } @@ -197,7 +235,7 @@ impl Default for GeneralSettings { fn default() -> Self { Self { reopen_last_project: true, - confirm_exit: false, + confirm_exit: true, open_project_in: "ask".into(), default_project_directory: String::new(), save_on_idle: false, @@ -510,6 +548,7 @@ pub struct LanguagesSettings { pub typescript: TypeScriptSettings, pub prettier: PrettierSettings, pub java: JavaSettings, + pub ruby: RubySettings, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -567,6 +606,13 @@ impl Default for PythonSettings { } } +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct RubySettings { + /// Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH). + pub interpreter: String, +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct NodeSettings { @@ -765,6 +811,24 @@ impl Settings { mod tests { use super::*; + #[test] + fn cmake_profile_argument_arrays_round_trip() { + let mut settings = Settings::default(); + assert!(settings.cmake.profiles.is_none()); + assert!(CMakeProfile::default().cmake_args.is_empty()); + assert!(CMakeProfile::default().build_args.is_empty()); + let profile = CMakeProfile { + cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(), + build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(), + ..Default::default() + }; + settings.cmake.profiles = Some(vec![profile.clone()]); + let value = serde_json::to_value(&settings).unwrap(); + assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args)); + assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args)); + assert_eq!(serde_json::from_value::(value).unwrap(), settings); + } + #[test] fn ui_font_fallback_defaults_empty_and_round_trips() { let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap(); diff --git a/crates/ide-core/src/text.rs b/crates/ide-core/src/text.rs index 45648128..6e21958d 100644 --- a/crates/ide-core/src/text.rs +++ b/crates/ide-core/src/text.rs @@ -97,6 +97,16 @@ pub struct TextEdit { #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] pub struct WorkspaceEdit { pub files: Vec, + /// Content fingerprints captured before presenting an edit for confirmation. + #[serde(default)] + #[specta(optional)] + pub preconditions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct WorkspaceEditPrecondition { + pub path: crate::IpcPath, + pub hash: String, } impl WorkspaceEdit { @@ -108,17 +118,26 @@ impl WorkspaceEdit { let mut resolve_aliases = true; for file in std::mem::take(&mut self.files) { match file { - FileEdit::Edit { path, edits } => { + FileEdit::Edit { path, edits, version } => { let path = if resolve_aliases { dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf()) } else { path.to_path_buf() }; let index = *batch.entry(path.clone()).or_insert_with(|| { - files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() }); + files.push(FileEdit::Edit { path: path.clone().into(), edits: Vec::new(), version }); files.len() - 1 }); - let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() }; + let FileEdit::Edit { edits: own, version: expected, .. } = &mut files[index] else { + unreachable!() + }; + if expected.is_some() && version.is_some() && *expected != version { + // Keep incompatible constraints so application rejects them + // before changing any file instead of silently losing one. + files.push(FileEdit::Edit { path: path.into(), edits, version }); + continue; + } + *expected = expected.or(version); for edit in edits { if !own.contains(&edit) { own.push(edit); @@ -140,10 +159,26 @@ impl WorkspaceEdit { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum FileEdit { - Edit { path: crate::IpcPath, edits: Vec }, - Create { path: crate::IpcPath, overwrite: bool }, - Rename { from: crate::IpcPath, to: crate::IpcPath }, - Delete { path: crate::IpcPath, recursive: bool }, + Edit { + path: crate::IpcPath, + edits: Vec, + /// LSP TextDocumentEdit version; null/unversioned edits use content preconditions. + #[serde(default)] + #[specta(optional)] + version: Option, + }, + Create { + path: crate::IpcPath, + overwrite: bool, + }, + Rename { + from: crate::IpcPath, + to: crate::IpcPath, + }, + Delete { + path: crate::IpcPath, + recursive: bool, + }, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)] @@ -254,7 +289,7 @@ mod workspace_edit_tests { std::fs::write(&real, "add()").unwrap(); std::os::unix::fs::symlink(&real, &alias).unwrap(); let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }; - let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] }; + let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()], version: None }; let mut workspace = WorkspaceEdit { files: vec![ edit(real.clone()), @@ -262,6 +297,7 @@ mod workspace_edit_tests { FileEdit::Create { path: dir.path().join("other").into(), overwrite: false }, edit(real), ], + ..Default::default() }; workspace.deduplicate(); assert_eq!(workspace.files.len(), 3); diff --git a/crates/ide-core/src/version_managers.rs b/crates/ide-core/src/version_managers.rs index 19e7cd4f..30eeae09 100644 --- a/crates/ide-core/src/version_managers.rs +++ b/crates/ide-core/src/version_managers.rs @@ -18,6 +18,7 @@ pub enum Tool { Go, Maven, Gradle, + Ruby, } impl Tool { @@ -30,6 +31,7 @@ impl Tool { Tool::Go => &["go", "golang"], Tool::Maven => &["maven"], Tool::Gradle => &["gradle"], + Tool::Ruby => &["ruby"], } } } diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index 64fb58fb..3b31a576 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -41,6 +41,8 @@ impl Default for VcsSettings { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct GitSettings { + /// Show blame after the current editor line. + pub inline_blame: bool, /// Path to the git executable; empty = auto-detect. pub executable: String, /// Default of Update Project: `merge` | `rebase`. @@ -71,6 +73,7 @@ pub struct GitSettings { impl Default for GitSettings { fn default() -> Self { Self { + inline_blame: false, executable: String::new(), update_method: "merge".into(), auto_update_on_push_rejected: false, diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 638ec8d3..5b8786a1 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -1389,6 +1389,16 @@ impl DebugManager { }) } + /// Stops every session `key` started: the window that opened the project is closing. + pub async fn stop_key(&self, key: &str) { + let ids: Vec = self.sessions(Some(key)).into_iter().map(|s| s.id).collect(); + for id in ids { + if let Err(e) = self.stop(id).await { + tracing::debug!("debug session {id:?} did not stop: {e}"); + } + } + } + /// Stops a session and its child sessions. pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> { let session = self.session(s)?; diff --git a/crates/ide-docker/Cargo.toml b/crates/ide-docker/Cargo.toml index 1431fa54..ae06863d 100644 --- a/crates/ide-docker/Cargo.toml +++ b/crates/ide-docker/Cargo.toml @@ -13,8 +13,5 @@ specta.workspace = true tracing.workspace = true which = "8" -[target.'cfg(unix)'.dependencies] -libc = "0.2" - [dev-dependencies] tempfile.workspace = true diff --git a/crates/ide-docker/src/process.rs b/crates/ide-docker/src/process.rs index 6c08c005..3941991d 100644 --- a/crates/ide-docker/src/process.rs +++ b/crates/ide-docker/src/process.rs @@ -1,11 +1,16 @@ //! Child processes of the docker CLI: bounded runs and cancellable line streams. +use ide_core::process::terminate_tree; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::io::Read; use std::process::{Child, Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; +/// How long a cancelled or timed-out child's process group (CLI plugins such as compose) +/// gets to exit on `SIGTERM`, and clean up, before `SIGKILL`. +const KILL_GRACE: Duration = Duration::from_millis(20); + pub(crate) struct Output { pub stdout: String, pub stderr: String, @@ -20,36 +25,13 @@ pub(crate) enum Stream { fn prepare(cmd: &mut Command) { cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } - ide_core::process::prepare(cmd); + ide_core::process::prepare(ide_core::process::own_group(cmd)); } fn spawn(cmd: &mut Command) -> IdeResult { cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run docker: {e}"))) } -/// Kills the child and, on Unix, its whole process group (CLI plugins such as compose). -pub(crate) fn kill(child: &mut Child) { - #[cfg(unix)] - { - let pid = child.id() as libc::pid_t; - // SAFETY: plain syscall; the child leads its own process group (`prepare`). - unsafe { - libc::kill(-pid, libc::SIGTERM); - } - std::thread::sleep(Duration::from_millis(20)); - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - let _ = child.kill(); - let _ = child.wait(); -} - fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle { std::thread::spawn(move || { let mut buf = Vec::new(); @@ -88,11 +70,11 @@ pub(crate) fn run_with_input( break status; } if cancel.is_some_and(CancellationToken::is_cancelled) { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } if Instant::now() >= deadline { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::unavailable("docker did not respond in time")); } std::thread::sleep(Duration::from_millis(10)); @@ -166,7 +148,7 @@ pub(crate) fn stream( let mut stderr_tail: Vec = Vec::new(); while open > 0 { if cancel.is_cancelled() { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } match rx.recv_timeout(Duration::from_millis(50)) { @@ -189,7 +171,7 @@ pub(crate) fn stream( break status; } if cancel.is_cancelled() { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } std::thread::sleep(Duration::from_millis(10)); diff --git a/crates/ide-github/Cargo.toml b/crates/ide-github/Cargo.toml index 0e33a8e8..5d67e56a 100644 --- a/crates/ide-github/Cargo.toml +++ b/crates/ide-github/Cargo.toml @@ -13,6 +13,3 @@ serde_json.workspace = true specta.workspace = true tracing.workspace = true which = "8" - -[target.'cfg(unix)'.dependencies] -libc = "0.2" diff --git a/crates/ide-github/src/process.rs b/crates/ide-github/src/process.rs index d5eec750..e945645b 100644 --- a/crates/ide-github/src/process.rs +++ b/crates/ide-github/src/process.rs @@ -1,11 +1,16 @@ //! Child processes of the gh CLI: bounded runs and cancellable line streams. +use ide_core::process::terminate_tree; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::io::Read; use std::process::{Child, Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; +/// How long a cancelled or timed-out child's process group (gh spawns git and helpers) +/// gets to exit on `SIGTERM` before `SIGKILL`: git removes its lock files on `SIGTERM`. +const KILL_GRACE: Duration = Duration::from_millis(20); + pub(crate) struct Output { pub stdout: String, pub stderr: String, @@ -20,36 +25,13 @@ pub(crate) enum Stream { fn prepare(cmd: &mut Command) { cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } - ide_core::process::prepare(cmd); + ide_core::process::prepare(ide_core::process::own_group(cmd)); } fn spawn(cmd: &mut Command) -> IdeResult { cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run gh: {e}"))) } -/// Kills the child and, on Unix, its whole process group (gh spawns git and helpers). -pub(crate) fn kill(child: &mut Child) { - #[cfg(unix)] - { - let pid = child.id() as libc::pid_t; - // SAFETY: plain syscall; the child leads its own process group (`prepare`). - unsafe { - libc::kill(-pid, libc::SIGTERM); - } - std::thread::sleep(Duration::from_millis(20)); - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - let _ = child.kill(); - let _ = child.wait(); -} - fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle { std::thread::spawn(move || { let mut buf = Vec::new(); @@ -92,11 +74,11 @@ pub(crate) fn run_with_input( break status; } if cancel.is_some_and(CancellationToken::is_cancelled) { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } if Instant::now() >= deadline { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::unavailable("gh did not respond in time")); } std::thread::sleep(Duration::from_millis(10)); @@ -170,7 +152,7 @@ pub(crate) fn stream( let mut stderr_tail: Vec = Vec::new(); while open > 0 { if cancel.is_cancelled() { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } match rx.recv_timeout(Duration::from_millis(50)) { @@ -193,7 +175,7 @@ pub(crate) fn stream( break status; } if cancel.is_cancelled() { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } std::thread::sleep(Duration::from_millis(10)); diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index c6330434..6f2c1ce5 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -51,4 +51,11 @@ uuid.workspace = true tempfile.workspace = true [dev-dependencies] +ide-ssh = { workspace = true, features = ["testing"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] } + +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1", features = ["fs", "process"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } diff --git a/crates/ide-host/src/bin/server.rs b/crates/ide-host/src/bin/server.rs index 6f405fcb..ada210d8 100644 --- a/crates/ide-host/src/bin/server.rs +++ b/crates/ide-host/src/bin/server.rs @@ -55,6 +55,7 @@ async fn run(base: PathBuf) -> i32 { let sink_peer = peer.clone(); let host = Arc::new(Host::new(HostConfig { config_dir, + local_data_dir: data_dir.clone(), data_dir, languages, syntax: Arc::default(), diff --git a/crates/ide-host/src/data_dirs.rs b/crates/ide-host/src/data_dirs.rs new file mode 100644 index 00000000..b76b168c --- /dev/null +++ b/crates/ide-host/src/data_dirs.rs @@ -0,0 +1,476 @@ +//! Storage policy and best-effort migration of rebuildable desktop data. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Unknown entries and recovery/user content deliberately stay in roaming data. +pub fn is_local(entry: &Path) -> bool { + ["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"] + .iter() + .any(|root| entry.starts_with(root)) + || entry.starts_with("agents/tools") + || entry == Path::new("agents/registry.json") + || (entry.components().count() == 1 + && entry.file_name().is_some_and(|name| name.to_string_lossy().starts_with("java-usages-")) + && entry.extension().is_some_and(|ext| ext == "jar" || ext == "tmp")) +} + +pub fn data_path(data: &Path, local: &Path, entry: &Path) -> PathBuf { + if is_local(entry) { local.join(entry) } else { data.join(entry) } +} + +#[derive(Debug, PartialEq, Eq)] +enum Action { + Skip, + Rename, +} + +fn action(same_root: bool, source_exists: bool, target_exists: bool) -> Action { + if same_root || !source_exists || target_exists { Action::Skip } else { Action::Rename } +} + +#[derive(Debug, PartialEq, Eq)] +enum RenameFailure { + Copy, + KeepSource, +} + +fn rename_failure(error: &io::Error) -> RenameFailure { + if error.kind() == io::ErrorKind::CrossesDevices { RenameFailure::Copy } else { RenameFailure::KeepSource } +} + +/// Only Windows splits these paths. Renames are cheap; cross-volume copies run in +/// the background. Consumers always use Local, even when migration fails. +pub fn migrate(data: &Path, local: &Path) { + if cfg!(target_os = "windows") && data != local { + migrate_entries(data, local); + } +} + +fn migrate_entries(data: &Path, local: &Path) { + let mut entries: Vec = [ + "language-server-runtimes", + "index", + "cache", + "lsp", + "notebook-runtime", + "server", + "java-usages-1.1.1.jar", + "java-usages-1.2.0.jar", + "agents/registry.json", + ] + .into_iter() + .map(PathBuf::from) + .collect(); + // Virtual environments contain absolute interpreter paths. Keep the old copy + // intact; these tools are installed afresh in Local when requested. Registry + // agents also have absolute commands in user config, so leave legacy installs. + match fs::read_dir(data.join("tools")) { + Ok(tools) => { + for entry in tools { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + tracing::warn!(%error, "cannot enumerate legacy tool; keeping old data, using Local"); + continue; + } + }; + let name = entry.file_name(); + let venv = ide_tools::registry::TOOLS.iter().any(|tool| { + name == tool.name && matches!(tool.method, ide_tools::registry::Method::PipVenv { .. }) + }); + if venv { + tracing::info!(?name, "leaving non-relocatable legacy tool; reinstall into Local when needed"); + } else { + entries.push(Path::new("tools").join(name)); + } + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"), + } + let mut copies = Vec::new(); + for entry in entries { + let source = data.join(&entry); + let target = data_path(data, local, &entry); + match try_rename(&source, &target) { + Ok(true) => copies.push((source, target)), + Ok(false) => {} + Err(error) => { + tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local") + } + } + } + if !copies.is_empty() { + let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || { + for (source, target) in copies { + tracing::info!(?source, ?target, "copying legacy data in background"); + if let Err(error) = copy_then_remove(&source, &target) { + tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local"); + } + } + }); + if let Err(error) = result { + tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local"); + } + } +} + +/// Returns true when a cross-volume copy is needed. +fn try_rename(source: &Path, target: &Path) -> io::Result { + if action(source == target, source.try_exists()?, target.try_exists()?) == Action::Skip { + return Ok(false); + } + if fs::symlink_metadata(source)?.file_type().is_symlink() { + return Err(io::Error::other("refusing to migrate a linked data entry")); + } + fs::create_dir_all(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?; + // Relative wrappers work at the old location too, so even a failed rename + // leaves the old installation usable and needs no rollback. + relocate_shims(source, source)?; + match rename_missing(source, target) { + Ok(()) => { + tracing::info!(?source, ?target, "moved legacy data to Local"); + Ok(false) + } + Err(error) if rename_failure(&error) == RenameFailure::Copy => Ok(true), + Err(error) => Err(error), + } +} + +fn copy_then_remove(source: &Path, target: &Path) -> io::Result<()> { + if target.try_exists()? { + return Ok(()); + } + let parent = target.parent().ok_or_else(|| io::Error::other("missing data parent"))?; + fs::create_dir_all(parent)?; + let staging = parent.join(format!(".migration-{}", uuid::Uuid::new_v4())); + let result = (|| { + copy_tree(source, &staging)?; + relocate_shims(&staging, source)?; + // A tool may have been installed or a cache rebuilt while copying. Never + // merge into or overwrite it, and retain the original for recovery. + if target.try_exists()? { + tracing::info!(?source, ?target, "Local data created during migration; retaining legacy copy"); + return Ok(()); + } + rename_missing(&staging, target)?; + remove_entry(source)?; + tracing::info!(?source, ?target, "copied legacy data to Local"); + Ok(()) + })(); + if staging.exists() { + let _ = remove_entry(&staging); + } + result +} + +// std::fs::rename may replace even an empty directory on modern Windows. +#[cfg(windows)] +fn rename_missing(source: &Path, target: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::MoveFileExW; + + // canonicalize supplies extended-length paths, including redirected UNC roots. + let source = fs::canonicalize(source)?; + let target = fs::canonicalize(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)? + .join(target.file_name().ok_or_else(|| io::Error::other("missing data name"))?); + let wide = |path: &Path| -> io::Result> { + let mut value: Vec = path.as_os_str().encode_wide().collect(); + if value.contains(&0) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in data path")); + } + value.push(0); + Ok(value) + }; + let source = wide(&source)?; + let target = wide(&target)?; + // SAFETY: both paths are valid NUL-terminated UTF-16 buffers. No flags means + // neither replacement nor implicit cross-volume copying is permitted. + if unsafe { MoveFileExW(source.as_ptr(), target.as_ptr(), 0) } == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +// Filesystem tests run on Unix too; production migration is Windows-only. +#[cfg(not(windows))] +fn rename_missing(source: &Path, target: &Path) -> io::Result<()> { + if target.try_exists()? { + return Err(io::Error::from(io::ErrorKind::AlreadyExists)); + } + fs::rename(source, target) +} + +fn remove_entry(path: &Path) -> io::Result<()> { + if fs::symlink_metadata(path)?.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) } +} + +fn copy_tree(source: &Path, target: &Path) -> io::Result<()> { + let metadata = fs::symlink_metadata(source)?; + if metadata.file_type().is_symlink() { + return Err(io::Error::other("refusing to follow a link during data migration")); + } + if metadata.is_dir() { + fs::create_dir(target)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + copy_tree(&entry.path(), &target.join(entry.file_name()))?; + } + } else if metadata.is_file() { + fs::copy(source, target)?; + } else { + return Err(io::Error::other("unsupported data entry")); + } + Ok(()) +} + +/// Make our absolute .cmd wrappers relative to their bin directory. They remain +/// valid in both locations if the move must be rolled back. npm shims already use +/// relative paths; payloads and user configuration are never rewritten. +fn relocate_shims(root: &Path, source: &Path) -> io::Result<()> { + if source.parent().and_then(Path::file_name).is_none_or(|name| name != "tools") { + return Ok(()); + } + let bin = root.join("bin"); + if !bin.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(bin)? { + let path = entry?.path(); + if path.extension().is_none_or(|ext| ext != "cmd") { + continue; + } + let text = fs::read_to_string(&path)?; + if text.starts_with("@echo off\r\n") { + let updated = relative_wrapper(&text, source); + if updated != text { + fs::write(path, updated)?; + } + } + } + Ok(()) +} + +fn relative_wrapper(text: &str, source: &Path) -> String { + // PathBuf can contain both separators (notably roots joined with "tools/demo"). + // Compare normalized paths, but retain every other byte of the managed wrapper. + let normalized = text.replace('\\', "/"); + let prefix = source.to_string_lossy().replace('\\', "/"); + #[cfg(windows)] + let (normalized, prefix) = (normalized.to_ascii_lowercase(), prefix.to_ascii_lowercase()); + let mut updated = text.to_owned(); + let offsets: Vec<_> = normalized.match_indices(&prefix).map(|(offset, _)| offset).collect(); + for offset in offsets.into_iter().rev() { + let end = offset + prefix.len(); + if normalized.as_bytes().get(end) == Some(&b'/') { + updated.replace_range(offset..end, "%~dp0.."); + } + } + updated +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() { + let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n"; + assert_eq!( + relative_wrapper(text, Path::new("C:/old/tools/demo")), + "@echo off\r\n\"%~dp0..\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n" + ); + } + + #[test] + fn storage_policy_is_conservative_and_preserves_unix_layout() { + for entry in [ + "tools/jdks", + "language-server-runtimes", + "lsp/jdtls", + "index/x", + "cache/x", + "notebook-runtime", + "server", + "logs", + "agents/tools", + "agents/registry.json", + "java-usages-1.1.1.jar", + "java-usages-1.2.0.jar", + ] { + assert!(is_local(Path::new(entry)), "{entry}"); + assert_eq!( + data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)), + Path::new("local").join(entry) + ); + assert_eq!( + data_path(Path::new("data"), Path::new("data"), Path::new(entry)), + Path::new("data").join(entry) + ); + } + for entry in [ + "git-credentials.json", + "history", + "remote", + "scratches", + "consoles", + "plugins", + "workspaces", + "agents/models.json", + "agents/project", + "unknown", + ] { + assert!(!is_local(Path::new(entry)), "{entry}"); + assert_eq!( + data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)), + Path::new("roaming").join(entry) + ); + } + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn unix_startup_does_not_migrate_even_if_given_distinct_roots() { + assert_eq!(dirs::data_dir(), dirs::data_local_dir()); + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir_all(old.join("index")).unwrap(); + migrate(&old, &new); + assert!(old.join("index").exists()); + assert!(!new.exists()); + } + + #[test] + fn decisions_cover_missing_existing_and_failed_renames() { + assert_eq!(action(true, true, false), Action::Skip); + assert_eq!(action(false, false, false), Action::Skip); + assert_eq!(action(false, true, true), Action::Skip); + assert_eq!(action(false, true, false), Action::Rename); + assert_eq!(rename_failure(&io::Error::from(io::ErrorKind::CrossesDevices)), RenameFailure::Copy); + for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::AlreadyExists, io::ErrorKind::NotFound] { + assert_eq!(rename_failure(&io::Error::from(kind)), RenameFailure::KeepSource); + } + } + + #[test] + fn same_volume_moves_and_second_start_does_not_overwrite() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir_all(old.join("tools/demo/bin")).unwrap(); + let wrapper = format!("@echo off\r\n\"{}/payload.exe\" %*\r\n", old.join("tools/demo").display()); + fs::write(old.join("tools/demo/bin/demo.cmd"), wrapper).unwrap(); + fs::create_dir_all(old.join("tools/ty/venv")).unwrap(); + fs::write(old.join("git-credentials.json"), "secret").unwrap(); + fs::create_dir_all(old.join("index")).unwrap(); + migrate_entries(&old, &new); + assert!(!old.join("tools/demo").exists()); + assert!(old.join("tools/ty/venv").exists()); + assert!(old.join("git-credentials.json").exists()); + assert!(fs::read_to_string(new.join("tools/demo/bin/demo.cmd")).unwrap().contains("%~dp0..")); + fs::create_dir_all(old.join("index")).unwrap(); + fs::write(old.join("index/legacy"), "old").unwrap(); + migrate_entries(&old, &new); + assert!(old.join("index/legacy").exists()); + assert!(!new.join("index/legacy").exists()); + } + + #[cfg(unix)] + #[test] + fn copy_failure_does_not_publish_or_remove_source() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir(&old).unwrap(); + fs::write(old.join("payload"), "old").unwrap(); + std::os::unix::fs::symlink(old.join("payload"), old.join("link")).unwrap(); + assert!(copy_then_remove(&old, &new).is_err()); + assert!(old.join("payload").exists()); + assert!(!new.exists()); + assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1); + } + + #[test] + fn staged_copy_publishes_complete_data_and_preserves_existing_target() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir_all(old.join("nested")).unwrap(); + fs::write(old.join("nested/tool.exe"), "payload").unwrap(); + copy_then_remove(&old, &new).unwrap(); + assert!(!old.exists()); + assert_eq!(fs::read_to_string(new.join("nested/tool.exe")).unwrap(), "payload"); + fs::create_dir(&old).unwrap(); + fs::write(old.join("legacy"), "old").unwrap(); + copy_then_remove(&old, &new).unwrap(); + assert!(old.join("legacy").exists()); + assert!(!new.join("legacy").exists()); + let old_file = tmp.path().join("registry-old.json"); + let new_file = tmp.path().join("registry-new.json"); + fs::write(&old_file, "cached registry").unwrap(); + copy_then_remove(&old_file, &new_file).unwrap(); + assert!(!old_file.exists()); + assert_eq!(fs::read_to_string(new_file).unwrap(), "cached registry"); + } + + #[test] + fn publication_refuses_existing_files_and_empty_directories() { + let tmp = tempfile::tempdir().unwrap(); + for directory in [false, true] { + let old = tmp.path().join(if directory { "old-dir" } else { "old-file" }); + let new = tmp.path().join(if directory { "new-dir" } else { "new-file" }); + if directory { + fs::create_dir(&old).unwrap(); + fs::create_dir(&new).unwrap(); + fs::write(old.join("payload"), "old").unwrap(); + } else { + fs::write(&old, "old").unwrap(); + fs::write(&new, "new").unwrap(); + } + assert!(rename_missing(&old, &new).is_err()); + assert!(old.exists() && new.exists()); + if directory { + assert!(!new.join("payload").exists()); + } else { + assert_eq!(fs::read_to_string(new).unwrap(), "new"); + } + } + } + + #[test] + fn wrapper_failure_keeps_the_old_installation() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old/tools/demo"); + let new = tmp.path().join("new/tools/demo"); + fs::create_dir_all(old.join("bin")).unwrap(); + fs::write(old.join("bin/demo.cmd"), [0xff, 0xfe]).unwrap(); + assert!(try_rename(&old, &new).is_err()); + assert!(old.join("bin/demo.cmd").exists()); + assert!(!new.exists()); + } + + #[test] + fn failures_keep_source_without_publishing_partial_data() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let blocker = tmp.path().join("blocker"); + fs::create_dir(&old).unwrap(); + fs::write(old.join("payload"), "old").unwrap(); + fs::write(&blocker, "file").unwrap(); + assert!(try_rename(&old, &blocker.join("target")).is_err()); + assert!(copy_then_remove(&old, &blocker.join("target")).is_err()); + assert!(old.join("payload").exists()); + assert!(copy_then_remove(&tmp.path().join("missing"), &tmp.path().join("new")).is_err()); + assert!(!tmp.path().join("new").exists()); + assert!( + !fs::read_dir(tmp.path()) + .unwrap() + .flatten() + .any(|e| e.file_name().to_string_lossy().starts_with(".migration-")) + ); + } +} diff --git a/crates/ide-host/src/formatter.rs b/crates/ide-host/src/formatter.rs index 2da08e36..054497b3 100644 --- a/crates/ide-host/src/formatter.rs +++ b/crates/ide-host/src/formatter.rs @@ -7,6 +7,16 @@ use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit}; use ide_language::FormatterConfig; use ide_lsp::types::FormatOptions; use ide_tools::{Tools, formatter}; +use std::path::Path; + +const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"]; + +/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier +/// settings (languages, preference, on save) apply to both. +fn external_tool(file: &Path) -> &'static str { + let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some()); + if oxfmt { "oxfmt" } else { "prettier" } +} fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool { config.is_some_and(|c| c.tool == "prettier") @@ -44,16 +54,19 @@ pub async fn format( return Err(IdeError::invalid("The file is read-only")); } let config = config.unwrap().clone(); - let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path); - let tools = Tools::new(&host.data_dir); - let whole_selection = range.is_some() && !config.range; + let tool = external_tool(&snap.path); + let name = formatter::display_name(tool); + let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier"); + let tools = Tools::new(&host.local_data_dir); + let whole_selection = range.is_some() && !formatter::supports_range(tool, &config); let edits: IdeResult> = tokio::task::spawn_blocking(move || { let text = ide_text::Document::new(&snap.text); let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end))); - let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?; - command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?); - command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets)); - let formatted = formatter::run(command, &snap.text, &Default::default())?; + let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?; + let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?; + command.current_dir(dir); + command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets)); + let formatted = formatter::run(tool, command, &snap.text, &Default::default())?; Ok(text .diff(&formatted) .changes @@ -69,13 +82,13 @@ pub async fn format( if edits.is_ok() && whole_selection { project.sink.emit(IdeEvent::Notify { level: NotifyLevel::Info, - title: "Prettier".into(), - body: Some( - "Prettier does not support selection formatting for this language; formatted the whole file".into(), - ), + title: name.into(), + body: Some(format!( + "{name} does not support selection formatting for this language; formatted the whole file" + )), }); } - edits.map_err(|e| e.with_tool("prettier")) + edits.map_err(|e| e.with_tool(tool)) } #[cfg(test)] @@ -104,4 +117,19 @@ mod tests { assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false)); assert!(!external_selected(None, "rust", &settings, false)); } + + #[test] + fn oxfmt_config_selects_oxfmt_over_prettier() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("web/src")).unwrap(); + let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts")); + assert_eq!(external_tool(&web), "prettier"); + for config in OXFMT_CONFIGS { + std::fs::write(root.join("web").join(config), "{}").unwrap(); + assert_eq!(external_tool(&web), "oxfmt", "{config}"); + assert_eq!(external_tool(&api), "prettier", "{config}"); + std::fs::remove_file(root.join("web").join(config)).unwrap(); + } + } } diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 5937b972..898b2884 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -3,6 +3,7 @@ //! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server` //! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]). +pub mod data_dirs; pub mod environment; mod formatter; mod java_runtime; @@ -33,7 +34,10 @@ pub type SinkFactory = Box SharedSink + Send + Sync>; pub struct HostConfig { pub config_dir: PathBuf, + /// Persistent user state, including credentials and recovery history. pub data_dir: PathBuf, + /// Machine-local tools and rebuildable caches. + pub local_data_dir: PathBuf, pub languages: Arc, /// Grammars, possibly still being built on another thread. pub syntax: Arc>>, @@ -49,10 +53,14 @@ pub struct HostConfig { pub struct Host { pub trust: trust::TrustStore, pub config_dir: PathBuf, + /// Persistent user state, including credentials and recovery history. pub data_dir: PathBuf, + /// Machine-local tools and rebuildable caches. + pub local_data_dir: PathBuf, pub languages: Arc, syntax: Arc>>, pub global_settings: SettingsStore, + pub dependency_versions: Arc, pub terminals: TerminalManager, pub processes: ProcessManager, /// Debug sessions (adapters run on this machine, next to the project). @@ -76,8 +84,17 @@ pub struct Host { impl Host { pub fn new(config: HostConfig) -> Self { - let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime, project_environment } = - config; + let HostConfig { + config_dir, + data_dir, + local_data_dir, + languages, + syntax, + sink_for, + owner, + runtime, + project_environment, + } = config; let global_settings = SettingsStore::load(&config_dir, None); let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json"))); ssh.set_configs(global_settings.effective().ssh.configs); @@ -86,8 +103,11 @@ impl Host { proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")), remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))), ssh, - environment: environment::Environment::new(project_environment, data_dir.clone()), + environment: environment::Environment::new(project_environment, local_data_dir.clone()), global_settings, + dependency_versions: Arc::new(ide_workspace::dependencies::VersionCache::new( + data_dir.join("cache/dependency-versions"), + )), // Sessions, runs and terminals report to the window that started them (`window_sink`). terminals: TerminalManager::new(), processes: ProcessManager::new(), @@ -96,6 +116,7 @@ impl Host { syntax, config_dir, data_dir, + local_data_dir, sink_for, owner, runtime, @@ -153,14 +174,24 @@ impl Host { if let Some(bridge) = &project.kotlin_bridge { bridge.stop(); } + project.documents.clear_workspace_edit_history(); project.agents.shutdown(); project.notebooks.stop_admission(); + // What the closing window started goes with it — the builds and searches it + // asked for, its runs, its shells and its debug sessions — and the language + // servers finish shutting down before the window is gone, as IDEA closes a + // project's processes. + for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() { + self.cancel_request(request); + } + self.processes.kill_project(&project.root); + self.terminals.kill_key(self.owner_window(window)); self.shutdown_project_workers_blocking( vec![project.notebooks.clone()], project.kotlin_bridge.iter().cloned().collect(), + Some(project.lsp.clone()), + Some((self.debugger.clone(), window.to_owned())), ); - let lsp = project.lsp.clone(); - self.runtime.spawn(async move { lsp.shutdown_all().await }); } } @@ -170,6 +201,8 @@ impl Host { &self, notebooks: Vec>, bridges: Vec>, + lsp: Option>, + debug: Option<(Arc, String)>, ) { for bridge in &bridges { bridge.stop(); @@ -187,6 +220,12 @@ impl Host { for service in notebooks { service.shutdown().await; } + if let Some(lsp) = lsp { + lsp.shutdown_all().await; + } + if let Some((debug, key)) = debug { + debug.stop_key(&key).await; + } }); }); if let Err(error) = worker.join() { @@ -208,9 +247,18 @@ impl Host { self.shutdown_project_workers_blocking( projects.iter().map(|project| project.notebooks.clone()).collect(), projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(), + None, + None, ); } + /// Called before desktop exit, which may bypass document destructors. + pub fn clear_workspace_edit_history_on_exit(&self) { + for project in self.projects() { + project.documents.clear_workspace_edit_history(); + } + } + /// Stops what this host started: notebook kernels, language servers, agents, runs and terminals. /// For a process that exits without dropping the host (the remote server). pub async fn shutdown(&self) { @@ -222,6 +270,7 @@ impl Host { if let Some(bridge) = &project.kotlin_bridge { bridge.stop(); } + project.documents.clear_workspace_edit_history(); project.notebooks.stop_admission(); project.agents.shutdown(); } diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index 47f089f9..5c16f270 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -25,7 +25,7 @@ pub fn providers(host: &Host, window: &str) -> IdeResult> { /// Where ACP Registry agents are saved (`/agents`, like the user's own) and installed. pub(crate) fn registry_paths(host: &Host) -> RegistryPaths { - RegistryPaths::new(&host.config_dir, &host.data_dir) + RegistryPaths::new(&host.config_dir, &host.local_data_dir) } fn join_err(e: impl std::fmt::Display) -> IdeError { @@ -63,13 +63,21 @@ pub async fn registry_install( .await .map_err(join_err); host.end_request(request); - result? + result??; + for project in host.projects() { + project.agents.reload_providers(); + } + Ok(()) } /// Removes a registry agent: its agent file and installed files. pub async fn registry_remove(host: &Host, _window: &str, id: String) -> IdeResult<()> { let paths = registry_paths(host); - tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)? + tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)??; + for project in host.projects() { + project.agents.reload_providers(); + } + Ok(()) } /// Slash commands to complete for `provider` in `task`'s directory, or in the project diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index ad398330..6ae0b0bd 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -133,22 +133,29 @@ pub async fn build_sync( host.end_request(request); let model = result??; if tool == BuildTool::Maven - && let Some(maven) = &model.maven + && model.maven.is_some() + && let Some(bridge) = &project.kotlin_bridge { - // jdtls re-imports on pom changes but misses artifacts added to ~/.m2 - // meanwhile ("missing artifact" stays until Invalidate Caches); asking it - // to update the project configurations re-resolves them (#436). - if let Some(bridge) = &project.kotlin_bridge { - bridge.configure_maven(offline, bridge_profiles); - } - let lsp = project.lsp.clone(); - let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect(); - tokio::spawn(async move { - if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await { - tracing::debug!("java/projectConfigurationsUpdate: {e}"); - } - }); + bridge.configure_maven(offline, bridge_profiles); } + // jdtls re-imports on pom changes but misses artifacts added to ~/.m2 + // meanwhile ("missing artifact" stays until Invalidate Caches); asking it + // to update the project configurations re-resolves them (#436). The same + // holds for Gradle: it reuses its cached classpath on restart, so a + // dependency added to `build.gradle` stays unresolved until told to sync. + let build_files: Vec = match tool { + BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(), + BuildTool::Gradle => { + model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect() + } + }; + let lsp = project.lsp.clone(); + let uris = build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect(); + tokio::spawn(async move { + if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await { + tracing::debug!("java/projectConfigurationsUpdate: {e}"); + } + }); Ok(model) } @@ -203,8 +210,10 @@ pub async fn maven_exclude_dependency( let (path, text, range) = declaring_pom(&project, &pom, &dependency)?; match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) { Some(Exclusion::Edit(edit)) => { - let edit = - WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] }; + let edit = WorkspaceEdit { + files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit], version: None }], + ..Default::default() + }; apply_workspace_edit(&project.documents, &edit)?; } Some(Exclusion::Present) => {} diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 492ce842..2af1eedd 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -39,7 +39,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide .await?; } if config.adapter == ide_dap::kotlin::ADAPTER { - let data_dir = host.data_dir.clone(); + let data_dir = host.local_data_dir.clone(); let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default(); let runtime_settings = settings.clone(); let runtime = host @@ -96,7 +96,13 @@ fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchC }; let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default(); let encoding = extra.get("encoding").and_then(|v| v.as_str()); - if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) { + if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args( + vm_args, + &config.env, + |key| std::env::var(key).ok(), + encoding, + windows, + ) { extra.insert("vmArgs".into(), vm_args.into()); config.extra_json = Some(serde_json::Value::Object(extra).to_string()); } @@ -452,7 +458,7 @@ mod tests { program: None, args: vec![], cwd: None, - env: Default::default(), + env: [("JDK_JAVA_OPTIONS".into(), "".into()), ("JAVA_TOOL_OPTIONS".into(), "".into())].into(), extra_json: Some(extra.to_string()), } } @@ -468,7 +474,7 @@ mod tests { let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true); assert_eq!( vm_args(&fixed), - "-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8" + "-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -Dfile.encoding=UTF-8" ); assert_eq!( serde_json::from_str::(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"], @@ -481,4 +487,13 @@ mod tests { assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g"); } } + + #[test] + fn java_debug_launches_respect_environment_encoding() { + for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] { + let mut config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Dstdout.encoding=GBK"})); + config.env.insert(key.into(), "-Dfile.encoding=GBK".into()); + assert_eq!(vm_args(&match_java_debug_encoding(config, true)), "-Dstdout.encoding=GBK"); + } + } } diff --git a/crates/ide-host/src/ops/dependencies.rs b/crates/ide-host/src/ops/dependencies.rs new file mode 100644 index 00000000..0dc62207 --- /dev/null +++ b/crates/ide-host/src/ops/dependencies.rs @@ -0,0 +1,124 @@ +//! Repository version lookups for manifest inspections and completion. +use crate::{Host, rpc::Dispatcher}; +use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId}; +use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo}; + +struct CratesIo; +impl DependencyVersionSource for CratesIo { + fn id(&self) -> &'static str { + "crates-io" + } + + fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult> { + let path = cargo::index_path(package).ok_or_else(|| IdeError::internal("invalid crate name"))?; + let search_path = std::env::var_os("PATH").unwrap_or_default(); + let text = ide_tools::Http::new(&search_path)?.https_only().get_text_limited( + &format!("https://index.crates.io/{path}"), + Some(16 * 1024 * 1024), + cancel, + )?; + cargo::parse_index(&text).ok_or_else(|| IdeError::internal("invalid sparse index")) + } +} + +/// `offset` limits a completion request to its version string; inspections omit it. +pub async fn dependency_versions( + host: &Host, + window: &str, + doc: DocId, + offset: Option, + request: RequestId, +) -> IdeResult> { + let project = host.project(window)?; + let cache = host.dependency_versions.clone(); + let cancel = host.begin_request(request); + let result = host + .blocking(move || { + let snap = project.documents.snapshot(doc)?; + if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + for dependency in cargo::dependencies(&snap.text) { + if offset.is_some_and(|pos| pos < dependency.from || pos > dependency.to) { + continue; + } + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let versions = cache.versions(&CratesIo, &dependency.package, &cancel)?; + let update = cargo::update(&dependency.requirement, &versions); + out.push(DependencyVersions { dependency, versions, update }); + } + Ok(out) + }) + .await; + host.end_request(request); + result +} + +pub(crate) fn register(d: &mut Dispatcher) { + d.add("dependency_versions", |c| async move { + dependency_versions(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?, c.arg("request")?).await + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::HostConfig; + use std::sync::Arc; + + struct Source; + impl DependencyVersionSource for Source { + fn id(&self) -> &'static str { + "crates-io" + } + fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult> { + Ok(vec!["1.10.0".into(), "0.2.1".into()]) + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn host_uses_synced_document_cache_and_cursor_filter_without_network() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + std::fs::create_dir(&root).unwrap(); + let text = "# 中文 😀\n[dependencies]\ntokio = { version = \"0.2\", features = [\"full\"] }\nserde = \"1.0.100\"\nlocal = { path = \"../x\", version = \"0.2\" }\n"; + let path = root.join("Cargo.toml"); + std::fs::write(&path, text).unwrap(); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(|_| {})), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + let project = host.open_project("main", &root).unwrap(); + let info = project.documents.open(&path).unwrap(); + for package in ["tokio", "serde"] { + host.dependency_versions.versions(&Source, package, &CancellationToken::new()).unwrap(); + } + let all = dependency_versions(&host, "main", info.id, None, RequestId(410)).await.unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].update.as_ref().unwrap().replacement, "1.10"); + assert!(all[1].update.is_none()); + let offset = all[1].dependency.from; + let at_cursor = dependency_versions(&host, "main", info.id, Some(offset), RequestId(411)).await.unwrap(); + assert_eq!(at_cursor.len(), 1); + assert_eq!(at_cursor[0].dependency.package, "serde"); + assert!(dependency_versions(&host, "main", info.id, Some(0), RequestId(412)).await.unwrap().is_empty()); + host.cancel_request(RequestId(413)); + assert!(dependency_versions(&host, "main", info.id, None, RequestId(413)).await.is_err()); + let other = root.join("other.toml"); + std::fs::write(&other, text).unwrap(); + let info = project.documents.open(&other).unwrap(); + assert!(dependency_versions(&host, "main", info.id, None, RequestId(414)).await.unwrap().is_empty()); + host.close_project("main"); + } +} diff --git a/crates/ide-host/src/ops/doc/commands.rs b/crates/ide-host/src/ops/doc/commands.rs index aa0f918b..e1fab8f9 100644 --- a/crates/ide-host/src/ops/doc/commands.rs +++ b/crates/ide-host/src/ops/doc/commands.rs @@ -127,7 +127,7 @@ pub(crate) fn register(d: &mut Dispatcher) { doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await }); d.add("doc_set_line_ending", |c| async move { - doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?) + blocking(move || doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)).await }); d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await }); d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await }); diff --git a/crates/ide-host/src/ops/doc/tests.rs b/crates/ide-host/src/ops/doc/tests.rs index 2f8d8bda..6c649bed 100644 --- a/crates/ide-host/src/ops/doc/tests.rs +++ b/crates/ide-host/src/ops/doc/tests.rs @@ -30,6 +30,7 @@ impl Fixture { let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(move |_| { diff --git a/crates/ide-host/src/ops/fs.rs b/crates/ide-host/src/ops/fs.rs index fb55f6f0..0277dfc4 100644 --- a/crates/ide-host/src/ops/fs.rs +++ b/crates/ide-host/src/ops/fs.rs @@ -56,14 +56,16 @@ pub async fn copy_merge( if overwrite { project.history.record_deletion(&to, DELETE_SNAPSHOT_LIMIT); } - ide_vfs::copy_merge( - &from, - &to, - overwrite, - Some(&project.root), - &project.exclude_globs(), - (&source_root, &destination_root), - ) + project.with_disk_sync(|| { + ide_vfs::copy_merge( + &from, + &to, + overwrite, + Some(&project.root), + &project.exclude_globs(), + (&source_root, &destination_root), + ) + }) }) .await } @@ -71,24 +73,28 @@ pub async fn copy_merge( /// Files of a deleted folder snapshotted in Local History. const DELETE_SNAPSHOT_LIMIT: usize = 2000; -pub fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult> { - let excludes = host - .project(window) - .map(|p| { - let globs = p.exclude_globs(); - // `read_dir` matches relative to the enclosing repository: re-anchor - // project-root patterns (`/build`) when the project is a repo subfolder. - let repo = p.root.ancestors().find(|d| d.join(".git").exists()); - match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) { - Some(rel) => { - let rel = rel.to_string_lossy().replace('\\', "/"); - globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect() +/// Runs on the blocking pool: on a network share (`\\wsl.localhost`) every stat is slow. +pub async fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult> { + let project = host.project(window); + host.blocking(move || { + let excludes = project + .map(|p| { + let globs = p.exclude_globs(); + // `read_dir` matches relative to the enclosing repository: re-anchor + // project-root patterns (`/build`) when the project is a repo subfolder. + let repo = p.root.ancestors().find(|d| d.join(".git").exists()); + match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) { + Some(rel) => { + let rel = rel.to_string_lossy().replace('\\', "/"); + globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect() + } + None => globs, } - None => globs, - } - }) - .unwrap_or_default(); - ide_vfs::read_dir(&path, &excludes) + }) + .unwrap_or_default(); + ide_vfs::read_dir(&path, &excludes) + }) + .await } /// Creates an empty file; inside a project, honours the EditorConfig diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index 47beb38d..098c1bab 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -59,9 +59,9 @@ async fn resolve_launch( .map_err(|e| IdeError::internal(e.to_string()))? }; let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None }; - let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?; + let mut info = project.lsp.java_launch(&module, test, build && !delegated, delegated, cancel).await?; let lsp_ms = started.elapsed().as_millis(); - let data_dir = host.data_dir.clone(); + let data_dir = host.local_data_dir.clone(); let cancel = cancel.clone(); tokio::task::spawn_blocking(move || { let _external_build = external_build; @@ -86,7 +86,7 @@ async fn resolve_launch( }; let mut class_paths: Vec = built.iter().map(|p| p.to_string_lossy().into_owned()).collect(); for p in std::mem::take(&mut info.class_paths) { - if !class_paths.contains(&p) { + if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) { class_paths.push(p); } } @@ -131,15 +131,44 @@ async fn resolve_launch( .map_err(|e| IdeError::internal(e.to_string()))? } +/// A classpath entry as a comparable key. Entries come from JDT LS and from our own +/// build model, which spell one directory differently on Windows (`\\?\` prefix, +/// `/` or `\\`, drive-letter case); compared as text, a module output stayed on the +/// classpath next to its launch snapshot and every resource was found twice. +fn entry_key(path: &str, windows: bool) -> String { + let path = match path.strip_prefix(r"\\?\UNC\") { + Some(share) => format!(r"\\{share}"), + None => path.strip_prefix(r"\\?\").unwrap_or(path).to_owned(), + }; + let mut key = if windows { path.replace('\\', "/") } else { path }; + while key.len() > 1 && key.ends_with('/') { + key.pop(); + } + if windows { key.to_lowercase() } else { key } +} + +fn same_entry(a: &str, b: &str, windows: bool) -> bool { + a == b || entry_key(a, windows) == entry_key(b, windows) +} + fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec { + use_snapshot_on(info, built, snapshot, cfg!(windows)) +} + +fn use_snapshot_on( + info: &mut JavaLaunchInfo, + built: &[PathBuf], + snapshot: &std::path::Path, + windows: bool, +) -> Vec { let mut copies = Vec::new(); for (index, source) in built.iter().enumerate() { let dest = snapshot.join(index.to_string()); let original = source.to_string_lossy(); - info.class_paths.retain(|p| p != original.as_ref()); + info.class_paths.retain(|p| !same_entry(p, &original, windows)); let mut modular = false; for path in &mut info.module_paths { - if path == original.as_ref() { + if same_entry(path, &original, windows) { *path = dest.to_string_lossy().into_owned(); modular = true; } @@ -185,6 +214,39 @@ pub(crate) fn register(d: &mut Dispatcher) { mod tests { use super::*; + /// JDT LS and our build model spell a module output differently on Windows; the + /// snapshot must still replace it, or resources (MyBatis mapper XML) load twice. + #[test] + fn snapshots_replace_outputs_however_windows_spells_them() { + let built = PathBuf::from(r"D:\workspace\arm\arm-web\target/classes"); + let jdtls = [ + r"D:\workspace\arm\arm-web\target\classes", + r"d:\workspace\arm\arm-web\target\classes", + r"D:/workspace/arm/arm-web/target/classes/", + r"\\?\D:\workspace\arm\arm-web\target\classes", + ]; + for spelled in jdtls { + let mut info = JavaLaunchInfo { + class_paths: vec![spelled.into(), r"D:\m2\mybatis.jar".into()], + module_paths: vec![spelled.into()], + ..Default::default() + }; + use_snapshot_on(&mut info, std::slice::from_ref(&built), std::path::Path::new(r"C:\cache\launch-x"), true); + assert_eq!(info.class_paths, [r"D:\m2\mybatis.jar"], "{spelled}"); + assert_eq!( + info.module_paths, + [std::path::Path::new(r"C:\cache\launch-x").join("0").to_string_lossy()], + "{spelled}" + ); + } + assert!(same_entry(r"\\?\UNC\server\share\app\target\classes", r"\\server\share\app\target/classes", true)); + // Different directories stay different, and other platforms keep case and `\`. + assert!(!same_entry(r"D:\a\target\classes", r"D:\a\target\test-classes", true)); + assert!(!same_entry("/work/App/target/classes", "/work/app/target/classes", false)); + assert!(!same_entry(r"/work/a\b", "/work/a/b", false)); + assert!(same_entry("/work/app/target/classes/", "/work/app/target/classes", false)); + } + #[test] fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index b57f54ef..f84ab3c7 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -1,9 +1,33 @@ +use super::doc::blocking; use crate::Host; use crate::apply_workspace_edit as apply_edit; use crate::rpc::Dispatcher; use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit}; use ide_lsp::types::*; +async fn prepare_actions( + project: std::sync::Arc, + doc: DocId, + version: u32, + mut actions: Vec, +) -> IdeResult> { + blocking(move || { + if project.documents.version(doc)? != version { + return Err(ide_core::IdeError::new( + ide_core::error::ErrorKind::OutOfSync, + "document changed while computing code actions; request them again", + )); + } + for action in &mut actions { + if let Some(edit) = &mut action.edit { + project.documents.prepare_workspace_edit(edit)?; + } + } + Ok(actions) + }) + .await +} + pub async fn lsp_completion( host: &Host, window: &str, @@ -138,7 +162,11 @@ pub async fn lsp_code_actions( range: Range, only: Vec, ) -> IdeResult> { - host.project(window)?.lsp.code_actions(doc, range, only).await + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; + let actions = project.lsp.code_actions(doc, range, only).await?; + prepare_actions(project, doc, version, actions).await } /// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability). @@ -150,11 +178,15 @@ pub async fn lsp_code_action_query( range: Range, only: Vec, ) -> IdeResult { - let lsp = host.project(window)?.lsp.clone(); + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; let cancel = host.begin_request(request); - let result = lsp.code_action_query(doc, range, only, cancel).await; + let result = project.lsp.code_action_query(doc, range, only, cancel).await; host.end_request(request); - result + let mut result = result?; + result.actions = prepare_actions(project, doc, version, result.actions).await?; + Ok(result) } /// Resolves a code action's edit without applying it (intention preview). @@ -167,19 +199,28 @@ pub async fn lsp_resolve_code_action( if action.edit.is_some() { return Ok(action); } - host.project(window)?.lsp.resolve_code_action(doc, action).await + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; + let action = project.lsp.resolve_code_action(doc, action).await?; + Ok(prepare_actions(project, doc, version, vec![action]).await?.remove(0)) } /// Resolves (if needed), applies the edit, then runs the command of a code action. pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<()> { let project = host.project(window)?; let action = if action.edit.is_none() && action.command.is_none() { - project.lsp.resolve_code_action(doc, action).await? + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; + let action = project.lsp.resolve_code_action(doc, action).await?; + prepare_actions(project.clone(), doc, version, vec![action]).await?.remove(0) } else { action }; if let Some(edit) = &action.edit { - apply_edit(&project.documents, edit)?; + let documents = project.documents.clone(); + let edit = edit.clone(); + blocking(move || apply_edit(&documents, &edit)).await?; } if let Some(command) = action.command { project.lsp.execute_command(doc, command).await?; @@ -200,11 +241,24 @@ pub async fn lsp_rename( pos: Pos, new_name: String, ) -> IdeResult { - let lsp = host.project(window)?.lsp.clone(); + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; let cancel = host.begin_request(request); - let result = lsp.rename(doc, pos, new_name, cancel).await; + let result = project.lsp.rename(doc, pos, new_name, cancel).await; host.end_request(request); - result + let mut renamed = result?; + blocking(move || { + if project.documents.version(doc)? != version { + return Err(ide_core::IdeError::new( + ide_core::error::ErrorKind::OutOfSync, + "document changed while computing rename; request it again", + )); + } + project.documents.prepare_workspace_edit(&mut renamed.edit)?; + Ok(renamed) + }) + .await } pub async fn lsp_format( @@ -279,21 +333,43 @@ pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResu } /// Applies a (previewed) workspace edit, e.g. after rename confirmation. -pub 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)?; - // 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) + blocking(move || { + // Multi-file refactorings leave a Local History point to go back to. + let paths: Vec<&std::path::Path> = edit + .files + .iter() + .filter_map(|f| match f { + ide_core::FileEdit::Edit { path, .. } => Some(&**path), + _ => None, + }) + .collect(); + if edit.files.len() > 1 { + project.record_before_refactoring(&paths, "Apply changes"); + } + apply_edit(&project.documents, &edit) + }) + .await +} + +pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult { + let project = host.project(window)?; + blocking(move || { + project.documents.prepare_workspace_edit(&mut edit)?; + Ok(edit) + }) + .await +} + +pub async fn undo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.documents.undo_workspace_edit(id)).await +} + +pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.documents.redo_workspace_edit(id)).await } /// Runs a server command (`workspace/executeCommand`), e.g. a completion item's @@ -441,7 +517,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) }); d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) }); d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await }); - d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) }); + d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?).await }); d.add("lsp_resolve_command", |c| async move { lsp_resolve_command( &c.host, @@ -465,6 +541,12 @@ pub(crate) fn register(d: &mut Dispatcher) { ) .await }); + d.add( + "prepare_workspace_edit", + |c| async move { prepare_workspace_edit(&c.host, &c.window, c.arg("edit")?).await }, + ); + d.add("redo_workspace_edit", |c| async move { redo_workspace_edit(&c.host, &c.window, c.arg("id")?).await }); + d.add("undo_workspace_edit", |c| async move { undo_workspace_edit(&c.host, &c.window, c.arg("id")?).await }); d.add("lsp_execute_command", |c| async move { lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await }); diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 228d02fc..c29dc4e9 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -8,6 +8,7 @@ pub mod build; pub mod bytes; pub mod coverage; pub mod debug; +pub mod dependencies; pub mod deploy; pub mod doc; pub mod env; @@ -21,6 +22,7 @@ pub mod notebook; pub mod project; mod python; pub mod refactor; +pub mod routes; pub mod run; pub mod sdk; pub mod search; @@ -69,6 +71,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) { analysis::register(d); syntax::register(d); spell::register(d); + dependencies::register(d); history::register(d); coverage::register(d); build::register(d); diff --git a/crates/ide-host/src/ops/refactor.rs b/crates/ide-host/src/ops/refactor.rs index 806f0f3d..f9a91ad6 100644 --- a/crates/ide-host/src/ops/refactor.rs +++ b/crates/ide-host/src/ops/refactor.rs @@ -1,7 +1,6 @@ //! File move / rename refactoring: reference updates from the language //! servers, the move itself (one undoable step) and its undo. use crate::Host; -use crate::apply_workspace_edit; use crate::rpc::Dispatcher; use ide_core::{IdeResult, RequestId, WorkspaceEdit}; @@ -26,7 +25,12 @@ pub async fn refactor_file_move_edits( let token = host.begin_request(request); let result = project.lsp.will_rename_files(&pairs(&moves), token).await; host.end_request(request); - result + let mut result = result?; + blocking(move || { + project.documents.prepare_workspace_edit(&mut result.edit)?; + Ok(result) + }) + .await } /// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the @@ -42,7 +46,7 @@ pub async fn refactor_move_files( let p = project.clone(); let moved = moves.clone(); let id = blocking(move || { - let apply = |e: &WorkspaceEdit| apply_workspace_edit(&p.documents, e); + let apply = |e: &WorkspaceEdit| p.documents.apply_file_move_edits(e); p.moves.perform(&p.documents, Some(&p.history), &moved, &edit, &label, &apply) }) .await?; @@ -77,13 +81,17 @@ pub async fn refactor_file_move_text_edits( let cancel = token.clone(); let result = blocking(move || { let replacements = ide_workspace::text_refs::move_replacements(&project.root, &project.roots(), &moves); - let edit = ide_workspace::text_refs::find_text_occurrences( + let mut edit = ide_workspace::text_refs::find_text_occurrences( &project.root, &project.exclude_globs(), &replacements, &|| cancel.is_cancelled(), ); - if cancel.is_cancelled() { Err(ide_core::IdeError::cancelled()) } else { Ok(edit) } + if cancel.is_cancelled() { + return Err(ide_core::IdeError::cancelled()); + } + project.documents.prepare_workspace_edit(&mut edit)?; + Ok(edit) }) .await; host.end_request(request); diff --git a/crates/ide-host/src/ops/routes.rs b/crates/ide-host/src/ops/routes.rs new file mode 100644 index 00000000..cbf2965e --- /dev/null +++ b/crates/ide-host/src/ops/routes.rs @@ -0,0 +1,134 @@ +//! Spring Web mappings, source side: the project's Java and Kotlin files that could +//! declare a route, with their text. The UI owns the annotation parser and the index +//! it feeds, so this side stays a cheap scan. + +use crate::Host; +use ide_core::IdeResult; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Files handed out at most, and bytes in total: a project's controllers fit well +/// inside these, and the UI indexes them once. +const MAX_FILES: usize = 1024; +const MAX_BYTES: usize = 48 << 20; +/// A single source bigger than this is not a controller. +const MAX_FILE_BYTES: u64 = 4 << 20; + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RouteSource { + pub path: String, + pub text: String, +} + +/// The files Spring mappings can live in: Java and Kotlin sources. +fn declares_mappings(path: &Path) -> bool { + matches!(path.extension().and_then(|e| e.to_str()), Some("java" | "kt" | "kts")) +} + +/// Files under `root` whose text names a mapping: `ignore` rules and `excludes` +/// decide what counts, and the UI ranks the matches itself. +pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec { + let found: Mutex> = Mutex::new(Vec::new()); + let bytes = AtomicUsize::new(0); + ide_vfs::walk_files(root, excludes, |path| { + if !declares_mappings(path) || bytes.load(Ordering::Relaxed) >= MAX_BYTES { + return; + } + if std::fs::metadata(path).map_or(true, |m| m.len() > MAX_FILE_BYTES) { + return; + } + let Ok(text) = std::fs::read_to_string(path) else { return }; + // Only a file that names a mapping annotation is worth parsing. + if !text.contains("Mapping") { + return; + } + let Ok(mut found) = found.lock() else { return }; + if found.len() >= MAX_FILES { + return; + } + bytes.fetch_add(text.len(), Ordering::Relaxed); + found.push(RouteSource { path: path.to_string_lossy().into_owned(), text }); + }); + let mut files = found.into_inner().unwrap_or_default(); + files.sort_by(|a, b| a.path.cmp(&b.path)); + files +} + +/// The folders to scan: the project folder itself first, then content roots outside it +/// (`content_roots` lists only the ones outside, so the project folder is never implied — +/// forgetting it scans nothing). +fn scan_roots(root: &Path, content_roots: Vec) -> Vec { + let mut roots = vec![root.to_path_buf()]; + roots.extend(content_roots); + roots +} + +/// Every mapping source of the project, for the request-path index. +pub fn spring_route_sources(host: &Host, window: &str) -> IdeResult> { + let project = host.project(window)?; + let excludes = project.search_excludes(); + let mut out = Vec::new(); + for root in scan_roots(&project.root, project.content_roots()) { + out.extend(sources_in(&root, &excludes)); + } + out.sort_by(|a, b| a.path.cmp(&b.path)); + out.dedup_by(|a, b| a.path == b.path); + match out.len() { + 0 => tracing::warn!("spring routes: no mapping source under {:?} (excludes: {})", project.root, excludes.len()), + n => tracing::info!("spring routes: {n} mapping sources under {:?}", project.root), + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_project_folder_is_scanned_even_without_other_content_roots() { + let root = Path::new("/p/demo"); + assert_eq!(scan_roots(root, Vec::new()), [PathBuf::from("/p/demo")]); + assert_eq!( + scan_roots(root, vec![PathBuf::from("/p/elsewhere")]), + [PathBuf::from("/p/demo"), PathBuf::from("/p/elsewhere")] + ); + } + + #[test] + fn only_java_and_kotlin_sources_are_read() { + for (name, expected) in + [("A.java", true), ("B.kt", true), ("C.kts", true), ("D.txt", false), ("pom.xml", false), ("noext", false)] + { + assert_eq!(declares_mappings(Path::new(name)), expected, "{name}"); + } + } + + #[test] + fn files_that_name_no_mapping_are_left_out() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + std::fs::write(tmp.path().join("src/Ctl.java"), "class Ctl { @GetMapping(\"/a\") void a() {} }").unwrap(); + std::fs::write(tmp.path().join("src/Plain.java"), "class Plain { void a() {} }").unwrap(); + std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap(); + let files = sources_in(tmp.path(), &[]); + assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::>()); + assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path); + assert!(files[0].text.contains("@GetMapping")); + } + + #[test] + fn excluded_folders_are_skipped() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("module/src")).unwrap(); + std::fs::create_dir_all(tmp.path().join("build")).unwrap(); + std::fs::write(tmp.path().join("module/src/A.java"), "@RequestMapping(\"/a\")").unwrap(); + std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap(); + let files = sources_in(tmp.path(), &["build/**".to_owned()]); + assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::>()); + assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path); + } +} diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index 21f21e0a..f172a4f3 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -50,10 +50,11 @@ fn detected_and_saved( suppressed: &[String], ) -> Vec { let excluded_poms = crate::project::unlinked_maven_poms(root, settings); - let mut all: Vec = ide_run::detect_excluding(root, &excluded_poms) - .into_iter() - .filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id)) - .collect(); + let mut all: Vec = + ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref()) + .into_iter() + .filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id)) + .collect(); all.extend(saved.iter().cloned()); for config in all.iter_mut().filter(|c| c.kind != "gradle") { ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings); @@ -61,13 +62,24 @@ fn detected_and_saved( all } +/// Saves the user configurations. They come from [`configs`], toolchains applied, so what +/// settings derived is dropped again: only the user's own choices reach `run.toml`. pub fn save_configs( host: &Host, window: &str, configs: &[RunConfig], removed_detected_ids: &[String], ) -> IdeResult<()> { - ide_run::save_user_configs_with_removals(&host.project(window)?.root, configs, removed_detected_ids) + let project = host.project(window)?; + let settings = project.settings.effective(); + let configs: Vec = configs + .iter() + .map(|c| match c.kind.as_str() { + "gradle" => c.clone(), + _ => ide_sdk::apply::without_derived(c, &project.root, &settings), + }) + .collect(); + ide_run::save_user_configs_with_removals(&project.root, &configs, removed_detected_ids) } pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit) -> IdeResult { diff --git a/crates/ide-host/src/ops/run/commands.rs b/crates/ide-host/src/ops/run/commands.rs index 7a916a51..c1b2acc7 100644 --- a/crates/ide-host/src/ops/run/commands.rs +++ b/crates/ide-host/src/ops/run/commands.rs @@ -5,6 +5,23 @@ use crate::rpc::Dispatcher; use ide_core::{IdeError, IdeResult}; use ide_run::RunConfig; +pub async fn run_dotnet_resolve(host: &Host, window: &str, config: RunConfig, debug: bool) -> IdeResult { + host.require_trust(window)?; + if !matches!(config.kind.as_str(), "dotnet" | "dotnet-test") + || config.extras.target.as_ref().is_some_and(|s| !s.is_empty()) + { + return Err(IdeError::invalid(".NET Project requires the local machine")); + } + let project = host.project(window)?; + let mut config = host.blocking(move || ide_run::dotnet::resolve(&project.root, config)).await?; + if debug { + let root = host.project(window)?.root.clone(); + let target = ide_run::dotnet::target_path(&root, &config).await?; + config.extras.params.insert("targetPath".into(), target); + } + Ok(config) +} + fn application_cache(project: &crate::project::Project, id: &str) -> std::path::PathBuf { use std::hash::{Hash, Hasher}; let mut hash = std::collections::hash_map::DefaultHasher::new(); @@ -206,6 +223,9 @@ pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mod /// Commands of this module the remote server answers. pub(crate) fn register(d: &mut Dispatcher) { + d.add("run_dotnet_resolve", |c| async move { + run_dotnet_resolve(&c.host, &c.window, c.arg("config")?, c.arg("debug")?).await + }); d.add("run_application_build", |c| async move { run_application_build(&c.host, &c.window, c.arg("config")?, c.arg("module")?).await }); diff --git a/crates/ide-host/src/ops/term.rs b/crates/ide-host/src/ops/term.rs index 50d8ac8c..02a34026 100644 --- a/crates/ide-host/src/ops/term.rs +++ b/crates/ide-host/src/ops/term.rs @@ -3,6 +3,21 @@ use crate::Host; use ide_core::{IdeResult, TerminalId}; use ide_terminal::{TerminalInfo, TerminalOptions}; +mod images; + +const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024; + +pub async fn store_image(host: &Host, id: TerminalId, data: Vec, mime: String) -> IdeResult { + if data.len() > MAX_IMAGE_BYTES { + return Err(ide_core::IdeError::invalid("image is too large to paste (limit 20 MB)")); + } + if !host.terminals.contains(id) { + return Err(ide_core::IdeError::invalid("terminal session not found")); + } + let data_dir = host.data_dir.clone(); + host.blocking(move || images::store(&data_dir, &data, &mime)).await +} + pub fn spawn( host: &Host, window: &str, @@ -25,7 +40,8 @@ pub fn spawn( .ok() .filter(|p| p.is_trusted()) .and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell)); - let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?; + let key = host.owner_window(window).to_owned(); + let info = host.terminals.spawn(options, key, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?; if let Some(line) = activation { let _ = host.terminals.write(info.id, &format!(" {line}\r")); } @@ -47,3 +63,34 @@ pub fn kill(host: &Host, id: TerminalId) -> IdeResult<()> { pub fn is_busy(host: &Host, id: TerminalId) -> IdeResult { host.terminals.is_busy(id) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::HostConfig; + use ide_core::{NullSink, error::ErrorKind}; + use std::sync::Arc; + + #[tokio::test] + async fn image_size_limit_is_checked_before_storage() { + let root = tempfile::tempdir().unwrap(); + let host = Host::new(HostConfig { + config_dir: root.path().join("config"), + data_dir: root.path().join("data"), + local_data_dir: root.path().join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + let id = TerminalId::next(); + let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES + 1], "image/png".into()).await.unwrap_err(); + assert_eq!(error.kind, ErrorKind::InvalidInput); + assert_eq!(error.message, "image is too large to paste (limit 20 MB)"); + let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES], "image/png".into()).await.unwrap_err(); + assert_eq!(error.message, "terminal session not found"); + assert!(!host.data_dir.join("terminal-images").exists()); + } +} diff --git a/crates/ide-host/src/ops/term/images.rs b/crates/ide-host/src/ops/term/images.rs new file mode 100644 index 00000000..e0c3261d --- /dev/null +++ b/crates/ide-host/src/ops/term/images.rs @@ -0,0 +1,533 @@ +use ide_core::{IdeError, IdeResult}; +use std::path::Path; + +const LIFETIME: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); +const DIRECTORY: &str = "terminal-images"; + +fn extension(mime: &str) -> IdeResult<&str> { + match mime { + "image/png" => Ok("png"), + "image/jpeg" => Ok("jpg"), + "image/gif" => Ok("gif"), + "image/webp" => Ok("webp"), + "image/bmp" => Ok("bmp"), + "image/svg+xml" => Ok("svg"), + "image/tiff" => Ok("tiff"), + "image/x-icon" => Ok("ico"), + "audio/wav" | "audio/x-wav" => Ok("wav"), + "audio/mpeg" => Ok("mp3"), + "audio/mp4" => Ok("m4a"), + "audio/ogg" => Ok("ogg"), + "audio/webm" | "video/webm" => Ok("webm"), + "audio/flac" => Ok("flac"), + "audio/aac" => Ok("aac"), + "video/mp4" => Ok("mp4"), + "video/ogg" => Ok("ogv"), + "video/quicktime" => Ok("mov"), + _ => Err(IdeError::invalid("unsupported clipboard media type")), + } +} + +fn owned_name(name: &str) -> bool { + let name = name.strip_prefix(".partial-").unwrap_or(name); + let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false }; + id.len() == 32 + && id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + && matches!( + ext, + "png" + | "jpg" + | "gif" + | "webp" + | "bmp" + | "svg" + | "tiff" + | "ico" + | "wav" + | "mp3" + | "m4a" + | "ogg" + | "webm" + | "flac" + | "aac" + | "mp4" + | "ogv" + | "mov" + ) +} + +#[cfg(unix)] +mod unix { + use super::*; + use rustix::fs::{self, AtFlags, Dir, Mode, OFlags}; + use std::fs::File; + use std::io::{self, Write}; + use std::os::unix::fs::MetadataExt; + use std::time::SystemTime; + + fn open_directory(data_dir: &Path) -> io::Result { + if !data_dir.is_absolute() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute")); + } + // The configured data directory and its ancestors are the host's trust boundary. + let parent = + fs::open(data_dir, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty())?; + match fs::mkdirat(&parent, DIRECTORY, Mode::from_bits_truncate(0o700)) { + Ok(()) | Err(rustix::io::Errno::EXIST) => (), + Err(e) => return Err(e.into()), + } + let dir: File = fs::openat( + &parent, + DIRECTORY, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )? + .into(); + let meta = dir.metadata()?; + if meta.uid() != rustix::process::geteuid().as_raw() || meta.mode() & 0o7777 != 0o700 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "terminal image directory is not private or owned by the current user", + )); + } + Ok(dir) + } + + fn cleanup(dir: &File, now: SystemTime) -> io::Result<()> { + for entry in Dir::read_from(dir)? { + let entry = entry?; + let name = entry.file_name(); + if !name.to_str().is_ok_and(owned_name) { + continue; + } + // Never follow symlinks; opening nonblocking avoids hanging on an unexpected FIFO. + let fd = match fs::openat( + dir, + name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(fd) => fd, + Err(_) => continue, + }; + let file = File::from(fd); + let meta = file.metadata()?; + if meta.is_file() + && meta.uid() == rustix::process::geteuid().as_raw() + && meta + .modified() + .ok() + .and_then(|time| now.duration_since(time).ok()) + .is_some_and(|age| age >= LIFETIME) + { + match fs::unlinkat(dir, name, AtFlags::empty()) { + Ok(()) | Err(rustix::io::Errno::NOENT) => (), + Err(error) => return Err(error.into()), + } + } + } + Ok(()) + } + + fn store_with( + data_dir: &Path, + data: &[u8], + mime: &str, + now: SystemTime, + write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>, + ) -> IdeResult { + let ext = extension(mime)?; + let dir = open_directory(data_dir)?; + cleanup(&dir, now)?; + let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext); + let temporary = format!(".partial-{name}"); + let fd = fs::openat( + &dir, + temporary.as_str(), + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_bits_truncate(0o600), + ) + .map_err(io::Error::from)?; + let mut file = File::from(fd); + if let Err(error) = write(&mut file, data) { + let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty()); + return Err(error.into()); + } + if let Err(error) = fs::linkat(&dir, temporary.as_str(), &dir, name.as_str(), AtFlags::empty()) { + let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty()); + return Err(io::Error::from(error).into()); + } + let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty()); + Ok(data_dir.join(DIRECTORY).join(name).to_string_lossy().into_owned()) + } + + pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult { + store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes)) + } + + #[cfg(test)] + mod tests { + use super::*; + use std::fs; + use std::os::unix::fs::{PermissionsExt, symlink}; + use std::time::Duration; + + #[test] + fn stores_bytes_with_private_modes_and_unpredictable_names() { + let root = tempfile::tempdir().unwrap(); + let first = store(root.path(), b"image bytes", "image/png").unwrap(); + let second = store(root.path(), b"other", "image/jpeg").unwrap(); + assert_ne!(first, second); + assert_eq!(fs::read(&first).unwrap(), b"image bytes"); + assert_eq!(fs::metadata(&first).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(fs::metadata(root.path().join(DIRECTORY)).unwrap().permissions().mode() & 0o777, 0o700); + assert!(owned_name(Path::new(&first).file_name().unwrap().to_str().unwrap())); + } + + #[test] + fn expiry_only_removes_owned_regular_files_at_boundary() { + let root = tempfile::tempdir().unwrap(); + let dir = open_directory(root.path()).unwrap(); + let path = root.path().join(DIRECTORY); + let now = SystemTime::now(); + let names = [ + "image-00000000000000000000000000000001.png", + "image-00000000000000000000000000000002.png", + "image-00000000000000000000000000000003.png", + "unrelated.png", + ]; + for (name, time) in names.iter().zip([ + now - LIFETIME, + now - LIFETIME + Duration::from_secs(1), + now + Duration::from_secs(1), + now - LIFETIME, + ]) { + let file = File::create(path.join(name)).unwrap(); + file.set_times(fs::FileTimes::new().set_modified(time)).unwrap(); + } + let link = path.join("image-00000000000000000000000000000004.png"); + symlink(path.join(names[0]), &link).unwrap(); + cleanup(&dir, now).unwrap(); + assert!(!path.join(names[0]).exists()); + for name in &names[1..] { + assert!(path.join(name).exists()); + } + assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink()); + } + + #[test] + fn rejects_symlink_and_insecure_directory() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let target = root.path().join(DIRECTORY); + symlink(outside.path(), &target).unwrap(); + assert!(store(root.path(), b"x", "image/png").is_err()); + fs::remove_file(&target).unwrap(); + fs::create_dir(&target).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(store(root.path(), b"x", "image/png").is_err()); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0); + } + + #[test] + fn failed_write_removes_partial_file() { + let root = tempfile::tempdir().unwrap(); + let result = store_with(root.path(), b"partial", "image/png", SystemTime::now(), |file, bytes| { + file.write_all(bytes)?; + Err(io::Error::other("simulated failure")) + }); + assert!(result.is_err()); + assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0); + } + } +} + +#[cfg(windows)] +mod windows { + use super::*; + use std::fs::{self, File, OpenOptions}; + use std::io::{self, Write}; + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + use std::time::SystemTime; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, + FILE_SHARE_WRITE, + }; + + fn open_directory(path: &Path) -> io::Result { + if !path.is_absolute() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute")); + } + let dir = OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)?; + let meta = dir.metadata()?; + if !meta.is_dir() || meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "terminal media directory must not be a reparse point", + )); + } + Ok(dir) + } + + fn cleanup(path: &Path, now: SystemTime) -> io::Result<()> { + for entry in fs::read_dir(path)? { + let entry = entry?; + if !entry.file_name().to_str().is_some_and(owned_name) { + continue; + } + let file = match OpenOptions::new().read(true).custom_flags(FILE_FLAG_OPEN_REPARSE_POINT).open(entry.path()) + { + Ok(file) => file, + Err(_) => continue, + }; + let meta = file.metadata()?; + if meta.is_file() + && meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 + && meta + .modified() + .ok() + .and_then(|time| now.duration_since(time).ok()) + .is_some_and(|age| age >= LIFETIME) + { + match fs::remove_file(entry.path()) { + Ok(()) => (), + Err(error) if error.kind() == io::ErrorKind::NotFound => (), + Err(error) => return Err(error), + } + } + } + Ok(()) + } + + fn store_with( + data_dir: &Path, + data: &[u8], + mime: &str, + now: SystemTime, + write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>, + ) -> IdeResult { + let ext = extension(mime)?; + let _parent = open_directory(data_dir)?; + let path = data_dir.join(DIRECTORY); + match fs::create_dir(&path) { + Ok(()) => (), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => (), + Err(error) => return Err(error.into()), + } + let _dir = open_directory(&path)?; + cleanup(&path, now)?; + let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext); + let target = path.join(&name); + let temporary = path.join(format!(".partial-{name}")); + let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary)?; + let result = write(&mut file, data); + drop(file); + if let Err(error) = result { + let _ = fs::remove_file(&temporary); + return Err(error.into()); + } + if let Err(error) = fs::hard_link(&temporary, &target) { + let _ = fs::remove_file(&temporary); + return Err(error.into()); + } + let _ = fs::remove_file(&temporary); + Ok(target.to_string_lossy().into_owned()) + } + + pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult { + store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes)) + } + + #[cfg(test)] + mod tests { + use super::*; + use std::time::Duration; + + fn junction(path: &Path, target: &Path) { + let output = std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "New-Item -ItemType Junction -Path $env:OPENIDEA_TEST_JUNCTION -Target $env:OPENIDEA_TEST_TARGET -ErrorAction Stop | Out-Null", + ]) + .env("OPENIDEA_TEST_JUNCTION", path) + .env("OPENIDEA_TEST_TARGET", target) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + } + + #[test] + fn rejects_reparse_data_and_storage_directories() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let link = root.path().join("data"); + junction(&link, outside.path()); + assert!(store(&link, b"x", "image/png").is_err()); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0); + fs::remove_dir(&link).unwrap(); + let link = root.path().join(DIRECTORY); + junction(&link, outside.path()); + assert!(store(root.path(), b"x", "image/png").is_err()); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0); + fs::remove_dir(&link).unwrap(); + } + + #[test] + fn expiry_only_removes_owned_regular_media_at_boundary() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let path = root.path().join(DIRECTORY); + fs::create_dir(&path).unwrap(); + let _dir = open_directory(&path).unwrap(); + let now = SystemTime::now(); + let names = [ + "image-00000000000000000000000000000001.png", + "image-00000000000000000000000000000002.mp3", + ".partial-image-00000000000000000000000000000003.mp4", + "image-00000000000000000000000000000004.webm", + "image-00000000000000000000000000000005.wav", + "unrelated.png", + "image-00000000000000000000000000000006.exe", + ]; + for (name, time) in names.iter().zip([ + now - LIFETIME, + now - LIFETIME, + now - LIFETIME, + now - LIFETIME + Duration::from_secs(1), + now + Duration::from_secs(1), + now - LIFETIME, + now - LIFETIME, + ]) { + let file = File::create(path.join(name)).unwrap(); + file.set_times(fs::FileTimes::new().set_modified(time)).unwrap(); + } + let link = path.join("image-00000000000000000000000000000007.png"); + junction(&link, outside.path()); + let directory = path.join("image-00000000000000000000000000000008.png"); + fs::create_dir(&directory).unwrap(); + cleanup(&path, now).unwrap(); + for name in &names[..3] { + assert!(!path.join(name).exists()); + } + for name in &names[3..] { + assert!(path.join(name).exists()); + } + assert!(directory.is_dir()); + assert!(fs::symlink_metadata(&link).unwrap().file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0); + fs::remove_dir(link).unwrap(); + } + + #[test] + fn failed_write_removes_partial_file() { + let root = tempfile::tempdir().unwrap(); + let result = store_with(root.path(), b"partial", "audio/ogg", SystemTime::now(), |file, bytes| { + file.write_all(bytes)?; + Err(io::Error::other("simulated failure")) + }); + assert!(result.is_err()); + assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0); + } + + #[test] + fn publication_preserves_existing_target() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join(DIRECTORY); + let result = store_with(root.path(), b"new", "video/mp4", SystemTime::now(), |file, bytes| { + file.write_all(bytes)?; + let temporary = fs::read_dir(&path)?.next().unwrap()?.file_name(); + let target = temporary.to_str().unwrap().strip_prefix(".partial-").unwrap(); + fs::write(path.join(target), b"existing") + }); + assert!(result.is_err()); + let files: Vec<_> = fs::read_dir(&path).unwrap().map(|entry| entry.unwrap().path()).collect(); + assert_eq!(files.len(), 1); + assert_eq!(fs::read(&files[0]).unwrap(), b"existing"); + assert!(!files[0].file_name().unwrap().to_str().unwrap().starts_with(".partial-")); + } + + #[test] + fn storage_handles_prevent_directory_replacement() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join(DIRECTORY); + let result = store_with(root.path(), b"x", "image/png", SystemTime::now(), |file, bytes| { + assert!(fs::rename(&path, root.path().join("replaced")).is_err()); + file.write_all(bytes) + }); + assert_eq!(fs::read(result.unwrap()).unwrap(), b"x"); + } + } +} + +#[cfg(unix)] +pub(super) use unix::store; + +#[cfg(windows)] +pub(super) use windows::store; + +#[cfg(not(any(unix, windows)))] +pub(super) fn store(_data_dir: &Path, _data: &[u8], _mime: &str) -> IdeResult { + Err(IdeError::internal("private terminal image storage is unsupported on this platform")) +} + +#[cfg(all(test, any(unix, windows)))] +mod tests { + use super::*; + + #[test] + fn stores_supported_media_with_expected_extensions_and_unique_names() { + let root = tempfile::tempdir().unwrap(); + let types = [ + ("image/png", "png"), + ("image/jpeg", "jpg"), + ("image/gif", "gif"), + ("image/webp", "webp"), + ("image/bmp", "bmp"), + ("image/svg+xml", "svg"), + ("image/tiff", "tiff"), + ("image/x-icon", "ico"), + ("audio/wav", "wav"), + ("audio/x-wav", "wav"), + ("audio/mpeg", "mp3"), + ("audio/mp4", "m4a"), + ("audio/ogg", "ogg"), + ("audio/webm", "webm"), + ("audio/flac", "flac"), + ("audio/aac", "aac"), + ("video/mp4", "mp4"), + ("video/webm", "webm"), + ("video/ogg", "ogv"), + ("video/quicktime", "mov"), + ]; + let mut paths = std::collections::HashSet::new(); + for (mime, ext) in types { + let path = store(root.path(), mime.as_bytes(), mime).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), mime.as_bytes()); + assert_eq!(Path::new(&path).extension().unwrap(), ext); + assert!(owned_name(Path::new(&path).file_name().unwrap().to_str().unwrap())); + assert!(paths.insert(path)); + } + assert_eq!(std::fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), types.len()); + } + + #[test] + fn rejects_unsupported_mime_before_creating_storage() { + let root = tempfile::tempdir().unwrap(); + for mime in ["text/plain", "application/octet-stream", "image/unknown", "audio/unknown", "video/unknown", ""] { + let error = store(root.path(), b"x", mime).unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + assert_eq!(error.message, "unsupported clipboard media type"); + } + assert!(!root.path().join(DIRECTORY).exists()); + } + + #[test] + fn rejects_relative_data_directory() { + assert!(store(Path::new("relative-data"), b"x", "image/png").is_err()); + } +} diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index f8f05b1f..b92399c2 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -12,7 +12,7 @@ use std::path::PathBuf; /// The installer, reporting the executables chosen in the window's settings. fn tools(host: &Host, window: &str) -> IdeResult { host.require_trust(window)?; - let data = host.data_dir.clone(); + let data = host.local_data_dir.clone(); let (settings, root) = match host.project(window) { Ok(project) => (project.settings.effective(), project.root.clone()), Err(_) => (host.global_settings.effective(), Default::default()), @@ -104,7 +104,7 @@ pub async fn jdk_download_releases( vendor: String, request: RequestId, ) -> IdeResult { - let tools = Tools::new(&host.data_dir); + let tools = Tools::new(&host.local_data_dir); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err); host.end_request(request); @@ -113,7 +113,7 @@ pub async fn jdk_download_releases( /// Where Download JDK puts JDKs unless told otherwise. pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult { - Ok(ide_tools::jdk::jdks_dir(&host.data_dir).into()) + Ok(ide_tools::jdk::jdks_dir(&host.local_data_dir).into()) } /// Downloads the latest `vendor` JDK of Java `version` into `location` (default: @@ -130,7 +130,7 @@ pub async fn jdk_download( request: RequestId, on_event: Channel, ) -> IdeResult { - let tools = Tools::new(&host.data_dir); + let tools = Tools::new(&host.local_data_dir); let location = location.filter(|p| !p.as_os_str().is_empty()); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || { diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 64d47e42..d8655e3b 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -123,18 +123,18 @@ fn per_repo( Ok(()) } -/// Shelf and patches work in the primary repository only: changes of a nested one -/// fail instead of being left out silently. -fn in_primary_repo(project: &Project, paths: &[PathBuf]) -> IdeResult<()> { +/// A shelf is one patch for one owning repository. Validate the whole selection before reverting files. +fn shelf_repository(project: &Project, paths: &[PathBuf]) -> IdeResult { let repos = project.repos(); - let primary = repos.primary().map(|r| r.root()); - match paths.iter().find(|p| repos.for_path(p).map(|r| r.root()) != primary) { - Some(p) => Err(ide_core::IdeError::invalid(format!( - "{} is in another git repository; shelving and patches support the project's main repository only", - p.display() - ))), - None => Ok(()), + let root = paths + .first() + .and_then(|p| repos.for_path(p)) + .map(|r| r.root()) + .ok_or_else(|| ide_core::IdeError::invalid("select files in a git repository to shelve"))?; + if paths.iter().any(|p| repos.for_path(p).map(|r| r.root()) != Some(root)) { + return Err(ide_core::IdeError::invalid("select files from a single git repository to shelve")); } + Ok(root.to_path_buf()) } async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { @@ -281,7 +281,7 @@ pub async fn vcs_add_to_ignore( } } apply_git_executable(&project.settings.effective()); - let result = ide_vcs::add_to_ignore(&paths, destination); + let result = project.with_disk_sync(|| ide_vcs::add_to_ignore(&paths, destination)); // Exclude may live outside a worktree's watched directory. Refresh filters and UI explicitly. project.watcher.reload_ignore_rules(); for (_, watcher) in project.content_watchers.lock().iter() { @@ -322,7 +322,7 @@ pub async fn vcs_commit( // Commit hooks may run for a long time. let project = host.project(window)?; blocking(move || { - project.with_repo_at(repo.as_deref(), |r| { + project.with_repo_recorded(repo.as_deref(), None, |r| { r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None)) }) }) @@ -729,7 +729,13 @@ pub async fn vcs_add_worktree( host.require_trust(window)?; let project = host.project(window)?; blocking(move || { - project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref())) + let result = project + .with_repo_recorded(repo.as_deref(), None, |r| r.add_worktree(&path, &commitish, new_branch.as_deref())); + // A worktree below the project is a new repository: rescan so its files + // join the status and gutter queries (the watcher picks the gitfile up + // too, but only as a debounced event later). + project.refresh_repositories(); + result }) .await } @@ -743,7 +749,12 @@ pub async fn vcs_remove_worktree( ) -> IdeResult<()> { host.require_trust(window)?; let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await + blocking(move || { + let result = project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force)); + project.refresh_repositories(); + result + }) + .await } pub async fn vcs_compare_scope(host: &Host, window: &str, paths: Vec) -> IdeResult { @@ -846,7 +857,7 @@ pub async fn vcs_apply_patch( ) -> IdeResult { host.require_trust(window)?; let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await + blocking(move || project.with_repo_recorded(repo.as_deref(), None, |r| r.apply_patch(&patch))).await } /// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them. @@ -854,16 +865,23 @@ pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec IdeResult> { let project = host.project(window)?; - ide_vcs::shelf::list(&shelf_dir(&project.root)) + let mut shelves = ide_vcs::shelf::list(&shelf_dir(&project.root))?; + let primary = project.repos().primary().map(|r| ide_core::IpcPath::from(r.root().to_path_buf())); + for entry in &mut shelves { + if entry.repo.is_none() { + entry.repo = primary.clone(); + } + } + Ok(shelves) } /// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the @@ -878,7 +896,8 @@ pub async fn vcs_unshelve( host.require_trust(window)?; let project = host.project(window)?; blocking(move || { - project.with_repo_recorded(None, Some("Unshelve"), |r| { + let repo = ide_vcs::shelf::repository(&shelf_dir(&project.root), &id)?; + project.with_repo_recorded(repo.as_deref(), Some("Unshelve"), |r| { ide_vcs::shelf::unshelve_path(r, &shelf_dir(&project.root), &id, remove, path.as_deref()) }) }) @@ -902,12 +921,30 @@ pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option IdeResult> { let project = host.project(window)?; - blocking(move || project.with_repo(|r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))).await + blocking(move || { + let repo = ide_vcs::shelf::repository(&shelf_dir(&project.root), &id)?; + project.with_repo_at(repo.as_deref(), |r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id)) + }) + .await } -pub async fn vcs_shelf_import(host: &Host, window: &str, name: String, patch: String) -> IdeResult<()> { +pub async fn vcs_shelf_import( + host: &Host, + window: &str, + repo: Option, + name: String, + patch: String, +) -> IdeResult<()> { let project = host.project(window)?; - blocking(move || ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch)).await + blocking(move || { + if repo.is_none() && project.repos().is_empty() { + return ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch, None); + } + project.with_repo_at(repo.as_deref(), |r| { + ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch, Some(r.root())) + }) + }) + .await } pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult { @@ -922,13 +959,20 @@ pub fn vcs_changelists_save(host: &Host, window: &str, changelists: ChangelistSt } /// Create Patch › Save to file. -pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch: String) -> IdeResult<()> { +pub async fn vcs_write_patch(host: &Host, window: &str, path: PathBuf, patch: String) -> IdeResult<()> { + let project = host.project(window).ok(); blocking(move || { - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir)?; + let write = || { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&path, patch)?; + Ok(()) + }; + match project { + Some(project) => project.with_disk_sync(write), + None => write(), } - std::fs::write(&path, patch)?; - Ok(()) }) .await } @@ -1107,7 +1151,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) }); d.add("vcs_shelf_diff", |c| async move { vcs_shelf_diff(&c.host, &c.window, c.arg("id")?).await }); d.add("vcs_shelf_import", |c| async move { - vcs_shelf_import(&c.host, &c.window, c.arg("name")?, c.arg("patch")?).await + vcs_shelf_import(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("patch")?).await }); d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?, c.arg("path")?) }); d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) }); diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index baf7e10e..e7d26808 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -112,7 +112,7 @@ impl Host { let project_key = project_key(&root); let index = ProjectIndex::open( &root, - &self.data_dir.join("index").join(&project_key), + &self.local_data_dir.join("index").join(&project_key), index_roots(&root, extra_roots.clone()), excludes.clone(), languages.clone(), @@ -127,7 +127,7 @@ impl Host { { let settings = settings.clone(); let root = root.clone(); - let data_dir = self.data_dir.clone(); + let data_dir = self.local_data_dir.clone(); let config_dir = self.config_dir.clone(); let sink = sink.clone(); let formatter_problem = std::sync::Mutex::new(None); @@ -214,7 +214,7 @@ impl Host { { let settings = settings.clone(); let root = root.clone(); - let data_dir = self.data_dir.clone(); + let data_dir = self.local_data_dir.clone(); let sink = sink.clone(); lsp.set_prepare_hook(Box::new(move |cfg, _, cancel| { let effective = settings.effective(); @@ -255,8 +255,8 @@ impl Host { let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new( root.clone(), - self.data_dir.join("cache").join(&project_key).join("build"), - &self.data_dir, + self.local_data_dir.join("cache").join(&project_key).join("build"), + &self.local_data_dir, settings.clone(), lsp.clone(), sink.clone(), @@ -325,15 +325,35 @@ impl Host { sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() }); } } - // A repository was cloned / initialised / removed below the project: its - // `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went. + // A repository was cloned / initialised / removed below the project: + // its `.git/HEAD` appeared or went. A linked worktree's `.git` is a + // plain gitfile, and that file is the only marker written inside the + // worktree itself, so the `.git` path counts too. let known = repos.read().roots(); - let git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| { - let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten() - else { - return false; + let mut git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| { + let dir = if p.ends_with(".git/HEAD") { + p.parent().and_then(Path::parent) + } else if p.ends_with(".git") { + p.parent() + } else { + None }; - known.iter().any(|r| r == dir) != p.is_file() + let Some(dir) = dir else { return false }; + known.iter().any(|r| r == dir) != p.exists() + }); + // A linked worktree was registered or unregistered in the main + // gitdir: `git worktree add` / `remove` creates or deletes + // `.git/worktrees/`. Its own gitfile is written before the + // watcher can watch the new directory, so this entry is what + // Linux per-directory watchers see. + git_dirs |= batch.vcs.iter().any(|p| { + let rest: Vec<_> = p + .components() + .skip_while(|c| c.as_os_str() != ".git") + .skip(1) // the `.git` component itself + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2) }); if git_dirs { scan_repositories(&root, &repos, &settings, &sink); @@ -345,6 +365,13 @@ impl Host { }), )? }; + if let Some(reason) = watcher.polling() { + sink.emit(IdeEvent::Notify { + level: ide_core::event::NotifyLevel::Warning, + title: "External file changes sync may be slow".into(), + body: Some(reason.into()), + }); + } // Scratch files live outside the project; watch them too so the // Project view and open editors follow changes made there. @@ -385,7 +412,7 @@ impl Host { let (db, http_history, agents) = self.project_services(&root, &project_key); let notebooks = crate::notebook::NotebookService::new( - self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), + self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), sink.clone(), ); ide_agent::registry::maintain(crate::ops::agent::registry_paths(self)); @@ -404,9 +431,9 @@ impl Host { structure: Arc::new(StructureService::new(syntax.clone())), syntax, detected_roots, - cache_dir: self.data_dir.join("cache").join(&project_key), + cache_dir: self.local_data_dir.join("cache").join(&project_key), java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( - self.data_dir.join("cache").join(&project_key).join("build"), + self.local_data_dir.join("cache").join(&project_key).join("build"), )), db, http_history, @@ -509,11 +536,11 @@ impl Host { let project_key = project_key(&dir); let (db, http_history, agents) = self.project_services(&dir, &project_key); let notebooks = crate::notebook::NotebookService::new( - self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), + self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), sink.clone(), ); let project = Arc::new(Project { - trusted: Arc::new(AtomicBool::new(true)), + trusted: Arc::new(AtomicBool::new(false)), sink, languages, settings, @@ -526,9 +553,9 @@ impl Host { structure: Arc::new(StructureService::new(syntax.clone())), syntax, detected_roots: Arc::default(), - cache_dir: self.data_dir.join("cache").join(&project_key), + cache_dir: self.local_data_dir.join("cache").join(&project_key), java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( - self.data_dir.join("cache").join(&project_key).join("build"), + self.local_data_dir.join("cache").join(&project_key).join("build"), )), db, http_history, @@ -682,17 +709,24 @@ impl Project { self.with_repo_at(None, f) } + pub(crate) fn with_disk_sync(&self, f: impl FnOnce() -> IdeResult) -> IdeResult { + let result = f(); + // Failed operations can also leave changed files (for example merge conflicts). + self.documents.sync_after_disk_change(); + result + } + /// Runs a VCS operation that rewrites the working tree (checkout, merge, /// rebase, pull, reset, stash, rollback): Local History gets a label and a /// snapshot of every locally changed file before, and a label after. - /// `None` runs `f` like [`with_repo`](Self::with_repo) (operations that keep the working tree). + /// Clean buffers follow the resulting files before returning; `None` omits the history labels. pub fn with_repo_recorded( &self, at: Option<&Path>, operation: Option<&str>, f: impl FnOnce(&Repo) -> IdeResult, ) -> IdeResult { - let Some(name) = operation else { return self.with_repo_at(at, f) }; + let Some(name) = operation else { return self.with_disk_sync(|| self.with_repo_at(at, f)) }; self.history_label(&format!("Before {name}")); if let Ok(changed) = self.with_repo_at(at, |r| r.status()) { for file in changed.iter().take(HISTORY_BATCH_LIMIT) { @@ -701,7 +735,7 @@ impl Project { } } } - let result = self.with_repo_at(at, f); + let result = self.with_disk_sync(|| self.with_repo_at(at, f)); self.history_label(&format!("After {name}")); result } @@ -881,37 +915,7 @@ impl Project { /// Applies a multi-file edit through the document layer so open editors update live. pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> { - use ide_core::FileEdit; - let mut edit = edit.clone(); - edit.deduplicate(); - for file in &edit.files { - match file { - FileEdit::Edit { path, edits } => { - // Files not open in an editor are opened transiently and closed again. - let (doc, transient) = match documents.by_path(path) { - Some(doc) => (doc, false), - None => (documents.open(path)?.id, true), - }; - let result = documents.apply_edits(doc, edits).and_then(|_| documents.save(doc)); - if transient { - documents.close(doc); - } - result?; - } - FileEdit::Create { path, overwrite } => { - if path.exists() { - if *overwrite { - ide_vfs::save_text(path, "", "utf-8", false)?; - } - } else { - ide_vfs::create_file(path)?; - } - } - FileEdit::Rename { from, to } => ide_vfs::rename(from, to)?, - FileEdit::Delete { path, .. } => ide_vfs::delete(path)?, - } - } - Ok(()) + documents.apply_workspace_edit(edit) } /// Files the watcher reports per batch that Local History snapshots. @@ -1253,10 +1257,14 @@ mod workspace_edit_tests { let doc = documents.open(&alias).unwrap(); let changes = |path: PathBuf| FileEdit::Edit { path: path.into(), + version: None, edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }], }; - apply_workspace_edit(&documents, &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)] }) - .unwrap(); + apply_workspace_edit( + &documents, + &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)], ..Default::default() }, + ) + .unwrap(); assert_eq!(std::fs::read_to_string(real).unwrap(), "sum_values();\n"); assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n"); } diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 78605b1e..2ab5bd7f 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -178,7 +178,7 @@ impl Dispatcher { Ok(()) }) .add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::("id")?)) }) - .add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) }) + .add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?).await }) .add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await }) .add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) }) .add("fs_create_file_bytes", |c| async move { @@ -215,9 +215,14 @@ impl Dispatcher { }) .add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await }) .add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await }) - .add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) }) + .add("doc_close", |c| async move { + ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await + }) .add("doc_edit", |c| async move { - ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?) + ops::doc::blocking(move || { + ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?) + }) + .await }) .add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await }) .add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await }) @@ -231,6 +236,9 @@ impl Dispatcher { let on_output = c.channel("onOutput")?; ops::term::spawn(&c.host, &c.window, c.arg("options")?, on_output) }) + .add("term_store_image", |c| async move { + ops::term::store_image(&c.host, c.arg("id")?, c.arg("data")?, c.arg("mime")?).await + }) .add("term_write", |c| async move { ops::term::write(&c.host, c.arg("id")?, &c.arg::("data")?) }) .add("term_resize", |c| async move { ops::term::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) }) .add("term_kill", |c| async move { ops::term::kill(&c.host, c.arg("id")?) }) diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index b829bd63..d0e0731d 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -135,9 +135,7 @@ impl crate::Host { for request in requests { self.cancel_request(request); } - for session in self.debugger.sessions(Some(&window)) { - let _ = self.debugger.stop(session.id).await; - } + self.debugger.stop_key(&window).await; } } project.lsp.reconfigure().await; @@ -211,6 +209,10 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "menu_model" | "menu_configure" | "menu_set_disabled" + | "prepare_workspace_edit" + | "apply_workspace_edit" + | "undo_workspace_edit" + | "redo_workspace_edit" | "lsp_servers" | "lsp_resend_diagnostics" | "recent_projects" @@ -222,6 +224,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "index_status" | "jvm_file_presentations" | "deploy_coverage" + | "deploy_test_connection" | "find_files" | "find_symbols" | "mark_recent" @@ -243,6 +246,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "remote_current" | "remote_disconnect" | "remote_connect" + | "remote_bootstrap_list" | "remote_list" | "remote_open" | "remote_of" diff --git a/crates/ide-host/tests/close_project.rs b/crates/ide-host/tests/close_project.rs new file mode 100644 index 00000000..e0b07cd1 --- /dev/null +++ b/crates/ide-host/tests/close_project.rs @@ -0,0 +1,54 @@ +//! Closing a project takes the background processes of its window with it, as IDEA does, +//! and leaves the other windows' processes alone. +#![cfg(unix)] + +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use std::sync::Arc; + +fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions { + ide_terminal::TerminalOptions { + cwd: root.to_path_buf().into(), + // A fixed shell: the test must not depend on the login shell of this machine. + shell: Some("/bin/sh".into()), + args: vec![], + env: Default::default(), + cols: 80, + rows: 24, + shell_integration: false, + } +} + +#[test] +fn closing_a_project_closes_its_terminals_and_keeps_other_windows() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap().join("project"); + std::fs::create_dir_all(&root).unwrap(); + let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap(); + runtime.block_on(async { + let host = Arc::new(Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + let project_shell = + ops::term::spawn(&host, "main", options(&root), Arc::new(|_| {})).expect("project terminal"); + let other_shell = ops::term::spawn(&host, "other", options(&root), Arc::new(|_| {})).expect("other terminal"); + assert_eq!(host.terminals.list().len(), 2); + + host.close_project("main"); + + let left: Vec<_> = host.terminals.list().into_iter().map(|t| t.id).collect(); + assert!(!left.contains(&project_shell.id), "the closing window's shell is gone"); + assert!(left.contains(&other_shell.id), "another window keeps its shell"); + host.terminals.kill_key("other"); + }); +} diff --git a/crates/ide-host/tests/data_dirs.rs b/crates/ide-host/tests/data_dirs.rs new file mode 100644 index 00000000..557a2b7d --- /dev/null +++ b/crates/ide-host/tests/data_dirs.rs @@ -0,0 +1,39 @@ +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use std::sync::Arc; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn split_storage_keeps_user_history_and_uses_local_tools_and_caches() { + let tmp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(tmp.path()).unwrap(); + let data = base.join("roaming"); + let local = base.join("local"); + let root = base.join("project"); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("notes.txt"), "hello\n").unwrap(); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: data.clone(), + local_data_dir: local.clone(), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + assert_eq!( + std::path::PathBuf::from(ops::tools::jdk_download_location(&host, "main").unwrap()), + local.join("tools/jdks") + ); + let light = host.open_light("main", &root.join("notes.txt")).unwrap(); + assert!(light.cache_dir.starts_with(&local)); + assert!(data.join("history").exists()); + assert!(!local.join("history").exists()); + let project = host.open_project("main", &root).unwrap(); + assert!(project.cache_dir.starts_with(&local)); + assert!(local.join("index").exists()); + assert!(!data.join("index").exists()); + assert!(!data.join("tools").exists()); + host.shutdown().await; +} diff --git a/crates/ide-host/tests/light_edit.rs b/crates/ide-host/tests/light_edit.rs index d3732431..99f2e96c 100644 --- a/crates/ide-host/tests/light_edit.rs +++ b/crates/ide-host/tests/light_edit.rs @@ -9,6 +9,7 @@ fn host(base: &std::path::Path) -> Arc { Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), @@ -77,3 +78,104 @@ async fn light_windows_ignore_project_scoped_settings() { assert!(!applied.project_scope); assert!(!dir.join(".openidea").exists(), "no project settings next to the file"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn light_windows_stay_restricted_when_folder_trust_changes() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("loose"); + let other = tmp.path().join("other"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write(root.join("one.py"), "print(1)\n").unwrap(); + std::fs::write(other.join("two.js"), "const value = 1;\n").unwrap(); + let host = host(&tmp.path().join("app")); + host.global_settings + .set(ide_workspace::SettingsScope::Global, "languages.prettier.prefer", serde_json::json!(true)) + .unwrap(); + let light = host.open_light("main", &root.join("one.py")).unwrap(); + assert!(!light.is_trusted()); + assert!(light.require_trust().is_err()); + + host.set_project_trust(&root, true, false).await.unwrap(); + assert!(!light.is_trusted()); + let trusted_folder = host.open_light("second", &root.join("one.py")).unwrap(); + assert!(!trusted_folder.is_trusted()); + let again = host.open_light("main", &other.join("two.js")).unwrap(); + assert!(Arc::ptr_eq(&light, &again)); + assert!(!again.is_trusted()); + host.set_project_trust(&root, true, true).await.unwrap(); + assert!(!light.is_trusted()); + assert!(!trusted_folder.is_trusted()); + for command in ["sdk_detect", "sdk_locate_tools", "notebook_execute", "notebook_restart", "run_start"] { + assert!(host.authorize_command("main", command).is_err(), "{command}"); + } + + let doc = ops::doc::open(&host, "main", other.join("two.js")).await.unwrap(); + let error = ops::lsp::lsp_format( + &host, + "main", + doc.id, + None, + ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }, + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable); + assert!(error.message.contains("Restricted Mode")); + + host.set_project_trust(&root, false, false).await.unwrap(); + assert!(!light.is_trusted()); + assert!(!trusted_folder.is_trusted()); + host.set_project_trust(&root, true, false).await.unwrap(); + let project = host.open_project("main", &root).unwrap(); + assert!(!project.light); + assert!(project.is_trusted()); + assert!(project.require_trust().is_ok()); + host.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn light_notebooks_open_without_interpreter_detection_or_execution() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("loose"); + let bin = root.join(".venv").join(if cfg!(windows) { "Scripts" } else { "bin" }); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(root.join(".venv/pyvenv.cfg"), "home = python\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let python = bin.join("python"); + std::fs::write(&python, "#!/bin/sh\nprintf probed > \"$0.probed\"\nprintf 'Python 3.12.0\\n'\n").unwrap(); + std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let file = root.join("analysis.ipynb"); + std::fs::write( + &file, + r#"{"cells":[{"id":"cell","cell_type":"code","source":["print(1)"],"metadata":{},"outputs":[],"execution_count":null}],"metadata":{},"nbformat":4,"nbformat_minor":5}"#, + ) + .unwrap(); + let host = host(&tmp.path().join("app")); + host.open_light("main", &file).unwrap(); + let notebook = ops::notebook::open(&host, "main", file.to_string_lossy().into()).await.unwrap(); + assert!(ops::notebook::save(&host, "main", notebook.id.clone()).await.is_ok()); + let error = ops::sdk::sdk_detect(&host, "main", ide_sdk::SdkKind::Python).await.unwrap_err(); + assert!(error.message.contains("Restricted Mode")); + assert!(ops::sdk::sdk_python_effective(&host, "main").is_err()); + assert!(ops::sdk::sdk_locate_tools(&host, "main", vec!["prettier".into()], Some(root)).await.is_err()); + let error = ops::notebook::restart(&host, "main", notebook.id.clone()).await.unwrap_err(); + assert!(error.message.contains("Restricted Mode")); + let error = ops::notebook::execute( + &host, + "main", + notebook.id, + vec![notebook.cells[0].id.clone()], + None, + ide_core::RequestId(849), + ops::Channel::new(|_| {}), + ) + .await + .unwrap_err(); + assert!(error.message.contains("Restricted Mode")); + assert!(!bin.join("python.probed").exists()); + host.shutdown().await; +} diff --git a/crates/ide-host/tests/properties.rs b/crates/ide-host/tests/properties.rs index dce783dd..2d8e5a1a 100644 --- a/crates/ide-host/tests/properties.rs +++ b/crates/ide-host/tests/properties.rs @@ -20,6 +20,7 @@ fn host(base: &Path) -> Host { Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/proxy.rs b/crates/ide-host/tests/proxy.rs index cef7910d..cf74d5dd 100644 --- a/crates/ide-host/tests/proxy.rs +++ b/crates/ide-host/tests/proxy.rs @@ -10,6 +10,7 @@ fn host(base: &std::path::Path) -> Arc { Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/remote_env.rs b/crates/ide-host/tests/remote_env.rs index 5f1fbf7c..121f3bed 100644 --- a/crates/ide-host/tests/remote_env.rs +++ b/crates/ide-host/tests/remote_env.rs @@ -79,6 +79,7 @@ fn server_environment_from_login_shell_and_devcontainer() { let host = Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(move |_| sink_events.clone() as SharedSink), diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index 58e4ede3..a0ec5004 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -56,6 +56,7 @@ async fn serves_projects_files_documents_search_and_runs() { let host = Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))), @@ -87,6 +88,40 @@ async fn serves_projects_files_documents_search_and_runs() { } let call = Invoke { window: "unknown".into(), cmd: "coverage_collect".into(), args: json!({}) }; assert!(peer.request::<_, Value>("invoke", call, None).await.is_err()); + // Manual workspace edits remain available in Restricted Mode, like doc/fs + // editing. They do not ask a language server to execute project commands. + let manual = project.join("src/manual.txt"); + std::fs::write(&manual, "old").unwrap(); + let edit = json!({ "files": [{ "kind": "edit", "path": manual, "edits": [{ + "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, + "new_text": "new" + }] }] }); + let edit = invoke(&peer, "prepare_workspace_edit", json!({ "edit": edit })).await.unwrap(); + assert_eq!(edit["preconditions"].as_array().unwrap().len(), 1); + invoke(&peer, "apply_workspace_edit", json!({ "edit": edit })).await.unwrap(); + until("workspace edit history", || { + client.events.lock().iter().any(|event| matches!(event.event, ide_core::IdeEvent::WorkspaceEditApplied { .. })) + }) + .await; + let undo_id = client + .events + .lock() + .iter() + .find_map(|event| match &event.event { + ide_core::IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new"); + invoke(&peer, "undo_workspace_edit", json!({ "id": undo_id })).await.unwrap(); + assert_eq!(std::fs::read_to_string(&manual).unwrap(), "old"); + invoke(&peer, "redo_workspace_edit", json!({ "id": undo_id })).await.unwrap(); + assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new"); + assert_eq!(invoke(&peer, "project_current", json!({})).await.unwrap()["trusted"], false); + for command in ["lsp_execute_command", "lsp_apply_code_action"] { + let error = invoke(&peer, command, json!({})).await.unwrap_err(); + assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}"); + } invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap(); let current = invoke(&peer, "project_current", json!({})).await.unwrap(); assert_eq!(current["name"], "demo"); @@ -261,6 +296,7 @@ async fn local_deployment_runs_on_the_project_host() { let host = Arc::new(Host::new(HostConfig { config_dir: tmp.path().join("config"), data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(move |w| rpc::event_sink(sink_slot.clone(), Some(w.to_owned()))), @@ -328,6 +364,7 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() { let host = Arc::new(Host::new(HostConfig { config_dir: tmp.path().join("config"), data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(ide_core::NullSink)), diff --git a/crates/ide-host/tests/run_configs.rs b/crates/ide-host/tests/run_configs.rs index 339121f7..9313c756 100644 --- a/crates/ide-host/tests/run_configs.rs +++ b/crates/ide-host/tests/run_configs.rs @@ -6,6 +6,7 @@ fn host(base: &std::path::Path) -> Arc { Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/terminal_activation.rs b/crates/ide-host/tests/terminal_activation.rs index 9561a23e..3678ffdb 100644 --- a/crates/ide-host/tests/terminal_activation.rs +++ b/crates/ide-host/tests/terminal_activation.rs @@ -24,6 +24,7 @@ fn terminal_activation_uses_the_resolved_default_shell() { let host = Arc::new(Host::new(HostConfig { config_dir: tmp.path().join("config"), data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/terminal_images.rs b/crates/ide-host/tests/terminal_images.rs new file mode 100644 index 00000000..014d1430 --- /dev/null +++ b/crates/ide-host/tests/terminal_images.rs @@ -0,0 +1,51 @@ +#![cfg(any(unix, windows))] + +use ide_core::{NullSink, TerminalId}; +use ide_host::{Host, HostConfig, ops}; +use ide_terminal::TerminalOptions; +use std::sync::Arc; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn storage_requires_a_registered_terminal() { + let root = tempfile::tempdir().unwrap(); + let data_dir = root.path().join("data"); + std::fs::create_dir(&data_dir).unwrap(); + let host = Host::new(HostConfig { + config_dir: root.path().join("config"), + data_dir: data_dir.clone(), + local_data_dir: data_dir.clone(), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + assert!(ops::term::store_image(&host, TerminalId::next(), vec![1], "image/png".into()).await.is_err()); + assert!(!data_dir.join("terminal-images").exists()); + let terminal = host + .terminals + .spawn( + TerminalOptions { + cwd: root.path().into(), + shell: Some(if cfg!(windows) { "powershell.exe" } else { "/bin/sh" }.into()), + args: if cfg!(windows) { vec!["-NoLogo".into(), "-NoProfile".into()] } else { vec![] }, + env: Default::default(), + cols: 80, + rows: 24, + shell_integration: false, + }, + "main".into(), + Arc::new(NullSink), + Box::new(|_| {}), + ) + .unwrap(); + for mime in ["image/png", "audio/mpeg", "video/mp4"] { + let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], mime.into()).await.unwrap(); + assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]); + } + assert!(ops::term::store_image(&host, terminal.id, vec![1], "application/octet-stream".into()).await.is_err()); + host.terminals.kill(terminal.id).unwrap(); + assert!(ops::term::store_image(&host, terminal.id, vec![4], "image/png".into()).await.is_err()); + assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 3); +} diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index 4a1d0250..eb2868ad 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -11,6 +11,7 @@ fn host(base: &Path, environment: bool) -> Arc { Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), @@ -55,6 +56,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( "db_console_path", "db_export_file", "vcs_write_patch", + "remote_bootstrap_list", + "deploy_test_connection", ] { assert!(ide_host::trust::restricted_command_allowed(command), "{command}"); for window in ["main", "welcome"] { @@ -74,6 +77,15 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( "vcs_git_version", "docker_compose_op", "db_execute", + "remote_delete", + "remote_mkdir", + "remote_rename", + "remote_import_local", + "remote_bootstrap_exec", + "deploy_upload", + "deploy_download", + "deploy_delete_remote", + "deploy_sync", "unknown_command", ] { assert!(!ide_host::trust::restricted_command_allowed(command), "{command}"); @@ -108,6 +120,55 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( host.shutdown().await; } +#[tokio::test] +async fn welcome_window_browses_selected_ssh_host_without_executing_commands() { + use ide_core::remote_settings::{SshAuth, SshConfiguration}; + use ide_ssh::{FsConnection, SshFiles, SshManager}; + + let tmp = tempfile::tempdir().unwrap(); + let server_root = tmp.path().join("server"); + std::fs::create_dir_all(server_root.join("projects/demo")).unwrap(); + std::fs::write(server_root.join("projects/README.md"), "remote file").unwrap(); + let port = ide_ssh::testing::sshd(server_root, Arc::new(|cmd| panic!("unexpected SSH exec: {cmd}"))).await; + let ssh = Arc::new(SshManager::with_files( + tmp.path().join("secrets.json"), + SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None }, + )); + ssh.set_configs(vec![SshConfiguration { + id: "selected-host".into(), + host: "127.0.0.1".into(), + port: Some(port), + user: "dev".into(), + auth: SshAuth::Password, + ..Default::default() + }]); + ssh.set_secret("selected-host", Some("secret".into()), false); + assert!(ssh.session("selected-host").await.is_err()); + ssh.trust_host("selected-host").await.unwrap(); + + let mut host = host(&tmp.path().join("app"), false); + let state = Arc::get_mut(&mut host).unwrap(); + state.ssh = ssh; + state.remote = Arc::new(ide_remote::RemoteService::new(state.ssh.clone(), tmp.path().join("cache"))); + assert!(host.project("welcome").is_err()); + host.authorize_command("welcome", "remote_bootstrap_list").unwrap(); + let connection = FsConnection::Sftp { ssh: "selected-host".into() }; + let entries = host.remote.list(&connection, "/projects").await.unwrap(); + assert!(entries.iter().any(|e| e.path == "/projects/demo" && e.is_dir)); + assert!(entries.iter().any(|e| e.name == "README.md" && !e.is_dir)); + assert!(host.remote.list(&connection, "/projects/demo").await.unwrap().is_empty()); + host.authorize_command("welcome", "deploy_test_connection").unwrap(); + host.remote.test_connection(&connection, "/projects").await.unwrap(); + let local = tmp.path().join("local-deployment"); + std::fs::create_dir(&local).unwrap(); + ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into()) + .await + .unwrap(); + assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up"); + assert!(host.project("welcome").is_err()); + host.shutdown().await; +} + #[test] fn persists_user_decisions_locations_revocation_and_one_time_migration() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/tests/trust_git_binary.rs b/crates/ide-host/tests/trust_git_binary.rs index 0002b46b..d4f27820 100644 --- a/crates/ide-host/tests/trust_git_binary.rs +++ b/crates/ide-host/tests/trust_git_binary.rs @@ -12,6 +12,7 @@ async fn restricted_status_does_not_select_the_project_git_for_other_windows() { let host = Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/vcs_documents.rs b/crates/ide-host/tests/vcs_documents.rs new file mode 100644 index 00000000..b6ca6fbe --- /dev/null +++ b/crates/ide-host/tests/vcs_documents.rs @@ -0,0 +1,240 @@ +use ide_core::{ChangeSet, NullSink, TextChange}; +use ide_host::{Host, HostConfig, Project, ops::vcs}; +use std::{path::Path, process::Command, sync::Arc, time::Duration}; + +const ORIGINAL: &str = "SELECT *\nFROM patients\n\tLIMIT 10 OFFSET 0"; +const OTHER: &str = "SELECT id\nFROM patients\n\tLIMIT 20 OFFSET 0"; + +fn git(root: &Path, args: &[&str]) -> String { + let out = Command::new("git").current_dir(root).args(args).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8(out.stdout).unwrap() +} + +struct Fixture { + host: Host, + project: Arc, + path: std::path::PathBuf, + doc: ide_core::DocId, + _temp: tempfile::TempDir, +} + +impl Fixture { + async fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + // The command must synchronize documents even when watcher delivery is excluded. + let path = root.join("node_modules/query.sql"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + git(&root, &["init", "-b", "main"]); + git(&root, &["config", "user.name", "Test"]); + git(&root, &["config", "user.email", "test@example.com"]); + git(&root, &["config", "commit.gpgsign", "false"]); + git(&root, &["config", "core.autocrlf", "false"]); + std::fs::write(&path, ORIGINAL).unwrap(); + git(&root, &["add", "-f", "node_modules/query.sql"]); + git(&root, &["commit", "-m", "original"]); + git(&root, &["switch", "-c", "other"]); + std::fs::write(&path, OTHER).unwrap(); + git(&root, &["commit", "-am", "other"]); + git(&root, &["switch", "main"]); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.trust.decide(&root, true, false).unwrap(); + let project = host.open_project("main", &root).unwrap(); + for _ in 0..500 { + if !project.repos().is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let doc = project.documents.open(&path).unwrap().id; + Self { host, project, path, doc, _temp: temp } + } + + fn saved_edit(&self, text: &str) { + self.project.documents.replace_all(self.doc, text).unwrap(); + self.project.documents.save(self.doc).unwrap(); + } + + fn assert_clean(&self, text: &str) { + let snapshot = self.project.documents.snapshot(self.doc).unwrap(); + assert_eq!(snapshot.text, text); + assert!(!snapshot.dirty); + self.project.documents.save_all().unwrap(); + assert_eq!(std::fs::read_to_string(&self.path).unwrap(), text); + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.host.close_project("main"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn checkout_reloads_clean_open_documents_before_returning() { + let f = Fixture::new().await; + vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap(); + f.assert_clean(OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rollback_reloads_clean_open_documents_before_returning() { + let f = Fixture::new().await; + f.saved_edit(&format!("{ORIGINAL}\n")); + vcs::vcs_discard(&f.host, "main", vec![f.path.clone()]).await.unwrap(); + f.assert_clean(ORIGINAL); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stash_and_unstash_reload_clean_open_documents_before_returning() { + let f = Fixture::new().await; + let edited = format!("{ORIGINAL}\n"); + f.saved_edit(&edited); + vcs::vcs_stash_push(&f.host, "main", None, None, false, false).await.unwrap(); + f.assert_clean(ORIGINAL); + vcs::vcs_stash_apply(&f.host, "main", None, 0, true, false, None).await.unwrap(); + f.assert_clean(&edited); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn applying_patch_reloads_clean_open_documents_before_returning() { + let f = Fixture::new().await; + std::fs::write(&f.path, OTHER).unwrap(); + let patch = git(&f.project.root, &["diff"]); + git(&f.project.root, &["restore", "."]); + vcs::vcs_apply_patch(&f.host, "main", None, patch).await.unwrap(); + f.assert_clean(OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn writing_patch_file_reloads_its_clean_open_document() { + let f = Fixture::new().await; + vcs::vcs_write_patch(&f.host, "main", f.path.clone(), OTHER.into()).await.unwrap(); + f.assert_clean(OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn directory_compare_overwrite_reloads_clean_open_documents() { + let f = Fixture::new().await; + let base = f.project.root.parent().unwrap(); + let source = base.join("source"); + let destination = base.join("destination"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(source.join("query.sql"), OTHER).unwrap(); + let path = destination.join("query.sql"); + std::fs::write(&path, ORIGINAL).unwrap(); + let doc = f.project.documents.open(&path).unwrap(); + ide_host::ops::fs::copy_merge(&f.host, "main", source, destination, "query.sql".into(), true).await.unwrap(); + let snapshot = f.project.documents.snapshot(doc.id).unwrap(); + assert_eq!(snapshot.text, OTHER); + assert!(!snapshot.dirty); + f.project.documents.save_all().unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn failed_working_tree_operation_synchronizes_partial_disk_changes() { + let f = Fixture::new().await; + let result: ide_core::IdeResult<()> = f.project.with_repo_recorded(None, Some("Git merge"), |_| { + std::fs::write(&f.path, OTHER).unwrap(); + Err(ide_core::IdeError::invalid("merge stopped with conflicts")) + }); + assert!(result.is_err()); + f.assert_clean(OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn working_tree_operation_reloads_same_size_same_timestamp_changes() { + let f = Fixture::new().await; + let modified = std::fs::metadata(&f.path).unwrap().modified().unwrap(); + let replacement = ORIGINAL.replace("LIMIT 10", "LIMIT 20"); + f.project + .with_repo_recorded(None, Some("Git checkout"), |_| { + std::fs::write(&f.path, &replacement).unwrap(); + std::fs::File::options() + .write(true) + .open(&f.path) + .unwrap() + .set_times(std::fs::FileTimes::new().set_modified(modified)) + .unwrap(); + Ok(()) + }) + .unwrap(); + f.assert_clean(&replacement); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn working_tree_operation_does_not_discard_unsaved_edits() { + let f = Fixture::new().await; + let version = f.project.documents.snapshot(f.doc).unwrap().version; + f.project + .documents + .edit(f.doc, version, ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "-- local\n".into() }] }) + .unwrap(); + vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap(); + let snapshot = f.project.documents.snapshot(f.doc).unwrap(); + assert_eq!(snapshot.text, format!("-- local\n{ORIGINAL}")); + assert!(snapshot.dirty); + assert_eq!(std::fs::read_to_string(&f.path).unwrap(), OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn adding_local_exclude_reloads_open_ignore_document() { + let f = Fixture::new().await; + let path = f.project.root.join(".git/info/exclude"); + let doc = f.project.documents.open(&path).unwrap(); + let ignored = f.project.root.join("generated.txt"); + std::fs::write(&ignored, "generated\n").unwrap(); + vcs::vcs_add_to_ignore(&f.host, "main", vec![ignored], ide_vcs::IgnoreDestination::LocalExclude).await.unwrap(); + let disk = std::fs::read_to_string(&path).unwrap(); + assert!(disk.contains("/generated.txt")); + assert_eq!(f.project.documents.snapshot(doc.id).unwrap().text, disk); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn commit_hook_reloads_clean_open_documents() { + use std::os::unix::fs::PermissionsExt; + let f = Fixture::new().await; + f.saved_edit(&format!("{ORIGINAL}\n")); + git(&f.project.root, &["add", "-u"]); + let hook = f.project.root.join(".git/hooks/pre-commit"); + std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > node_modules/query.sql\n").unwrap(); + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + vcs::vcs_commit( + &f.host, + "main", + None, + "commit with hook".into(), + ide_vcs::CommitOptions { run_hooks: true, ..Default::default() }, + ) + .await + .unwrap(); + f.assert_clean("SELECT hook;"); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn worktree_checkout_hook_reloads_clean_open_documents() { + use std::os::unix::fs::PermissionsExt; + let f = Fixture::new().await; + let hook = f.project.root.join(".git/hooks/post-checkout"); + std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > ../project/node_modules/query.sql\n").unwrap(); + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + let linked = f.project.root.parent().unwrap().join("linked"); + vcs::vcs_add_worktree(&f.host, "main", None, linked, "other".into(), None).await.unwrap(); + f.assert_clean("SELECT hook;"); +} diff --git a/crates/ide-host/tests/vcs_encoding.rs b/crates/ide-host/tests/vcs_encoding.rs index 71b8e6a4..7da472b8 100644 --- a/crates/ide-host/tests/vcs_encoding.rs +++ b/crates/ide-host/tests/vcs_encoding.rs @@ -28,6 +28,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() { let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/vcs_roots.rs b/crates/ide-host/tests/vcs_roots.rs index d0431a1a..b833e8ed 100644 --- a/crates/ide-host/tests/vcs_roots.rs +++ b/crates/ide-host/tests/vcs_roots.rs @@ -31,6 +31,7 @@ async fn patches_read_and_write_the_requested_repository() { let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/vcs_shelf_roots.rs b/crates/ide-host/tests/vcs_shelf_roots.rs new file mode 100644 index 00000000..cb716546 --- /dev/null +++ b/crates/ide-host/tests/vcs_shelf_roots.rs @@ -0,0 +1,123 @@ +//! Shelves retain their repository even when the project root is above or below it. +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops::vcs}; +use std::{path::Path, process::Command, sync::Arc, time::Duration}; + +fn git(root: &Path, args: &[&str]) { + let output = Command::new("git").current_dir(root).args(args).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); +} + +async fn check_shelves(layout: &str) { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let project_root = base.join("project"); + let roots = match layout { + "same" => vec![project_root.clone()], + "below" => vec![base.clone()], + "two" => vec![project_root.join("项目A"), project_root.join("repoB")], + _ => vec![project_root.join("项目A")], + }; + std::fs::create_dir_all(project_root.join("dirB")).unwrap(); + for (i, root) in roots.iter().enumerate() { + std::fs::create_dir_all(root.join("src")).unwrap(); + git(root, &["init", "-b", "main"]); + git(root, &["config", "user.name", "Test"]); + git(root, &["config", "user.email", "test@example.com"]); + std::fs::write(root.join("src/same.txt"), format!("base {i}\n")).unwrap(); + git(root, &["add", "src"]); + git(root, &["commit", "-m", "initial"]); + std::fs::write(root.join("src/same.txt"), format!("shelved {i}\n")).unwrap(); + } + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.trust.decide(&project_root, true, false).unwrap(); + let project = host.open_project("main", &project_root).unwrap(); + for _ in 0..500 { + if project.repos().all().len() == roots.len() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(project.repos().all().len(), roots.len()); + let primary = project.repos().primary().unwrap().root().to_path_buf(); + if roots.len() == 2 { + assert!( + vcs::vcs_shelve(&host, "main", "mixed".into(), roots.iter().map(|r| r.join("src/same.txt")).collect()) + .await + .is_err() + ); + assert!(vcs::vcs_shelves(&host, "main").unwrap().is_empty()); + } + for (i, root) in roots.iter().enumerate() { + let entry = vcs::vcs_shelve(&host, "main", "work".into(), vec![root.join("src/same.txt")]).await.unwrap(); + assert_eq!(entry.repo.as_deref(), Some(root.as_path())); + assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("base {i}\n")); + let listed = vcs::vcs_shelves(&host, "main").unwrap(); + assert_eq!(listed.iter().find(|s| s.id == entry.id).unwrap().repo, entry.repo); + let diffs = vcs::vcs_shelf_diff(&host, "main", entry.id.clone()).await.unwrap(); + assert_eq!(diffs[0].before.as_deref(), Some(format!("base {i}\n").as_str())); + assert_eq!(diffs[0].after.as_deref(), Some(format!("shelved {i}\n").as_str())); + vcs::vcs_shelf_rename(&host, "main", entry.id.clone(), "renamed".into()).unwrap(); + let patch = vcs::vcs_shelf_patch(&host, "main", entry.id.clone(), None).unwrap(); + vcs::vcs_shelf_import(&host, "main", Some(root.clone()), "imported".into(), patch).await.unwrap(); + let imported = vcs::vcs_shelves(&host, "main").unwrap().into_iter().find(|s| s.name == "imported").unwrap(); + assert_eq!(imported.repo, entry.repo); + vcs::vcs_shelf_delete(&host, "main", imported.id, None).unwrap(); + if root != &primary { + let info = ide_core::project_dir::path(&project_root, "shelf").join(&entry.id).join("shelf.json"); + let original = std::fs::read(&info).unwrap(); + let mut metadata: serde_json::Value = serde_json::from_slice(&original).unwrap(); + metadata["repo"] = serde_json::json!(base.join("missing-repository")); + std::fs::write(&info, serde_json::to_vec(&metadata).unwrap()).unwrap(); + assert!(vcs::vcs_shelf_diff(&host, "main", entry.id.clone()).await.is_err()); + assert!(vcs::vcs_unshelve(&host, "main", entry.id.clone(), false, None).await.is_err()); + assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("base {i}\n")); + std::fs::write(info, original).unwrap(); + } + vcs::vcs_unshelve(&host, "main", entry.id.clone(), false, Some("src/same.txt".into())).await.unwrap(); + assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("shelved {i}\n")); + // Old shelves were always made in the primary repository; missing ownership keeps that meaning. + if root == &primary { + let info = ide_core::project_dir::path(&project_root, "shelf").join(&entry.id).join("shelf.json"); + let mut metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(&info).unwrap()).unwrap(); + metadata.as_object_mut().unwrap().remove("repo"); + std::fs::write(info, serde_json::to_vec(&metadata).unwrap()).unwrap(); + assert_eq!( + vcs::vcs_shelves(&host, "main").unwrap().into_iter().find(|s| s.id == entry.id).unwrap().repo, + entry.repo + ); + assert_eq!( + vcs::vcs_shelf_diff(&host, "main", entry.id).await.unwrap()[0].after.as_deref(), + Some(format!("shelved {i}\n").as_str()) + ); + } + } + host.close_project("main"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelf_at_repo_root() { + check_shelves("same").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelf_in_non_git_project() { + check_shelves("above").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelves_in_two_repositories() { + check_shelves("two").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelf_in_project_below_repository() { + check_shelves("below").await; +} diff --git a/crates/ide-host/tests/vcs_worktrees.rs b/crates/ide-host/tests/vcs_worktrees.rs new file mode 100644 index 00000000..57304d18 --- /dev/null +++ b/crates/ide-host/tests/vcs_worktrees.rs @@ -0,0 +1,93 @@ +use ide_core::NullSink; +use ide_host::{Host, HostConfig, Project, ops::vcs}; +use std::{path::PathBuf, process::Command, sync::Arc, time::Duration}; + +fn git(root: &std::path::Path, args: &[&str]) { + let out = Command::new("git").current_dir(root).args(args).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); +} + +async fn wait_for_repos(project: &Arc, count: usize) { + for _ in 0..200 { + if project.repos().all().len() >= count { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("expected {} repositories, found {}", count, project.repos().all().len()); +} + +struct Fixture { + host: Host, + project: Arc, + root: PathBuf, + _temp: tempfile::TempDir, +} + +impl Fixture { + async fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + std::fs::create_dir_all(&root).unwrap(); + git(&root, &["init", "-b", "main"]); + git(&root, &["config", "user.name", "Test"]); + git(&root, &["config", "user.email", "test@example.com"]); + git(&root, &["config", "commit.gpgsign", "false"]); + git(&root, &["config", "core.autocrlf", "false"]); + std::fs::write(root.join("file.txt"), "original\n").unwrap(); + git(&root, &["add", "."]); + git(&root, &["commit", "-m", "original"]); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.trust.decide(&root, true, false).unwrap(); + let project = host.open_project("main", &root).unwrap(); + wait_for_repos(&project, 1).await; + Self { host, project, root, _temp: temp } + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.host.close_project("main"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn command_added_worktree_joins_the_repository_set() { + let f = Fixture::new().await; + let linked = f.root.join("linked"); + vcs::vcs_add_worktree(&f.host, "main", None, linked.clone(), "main".into(), Some("topic".into())).await.unwrap(); + let roots = f.project.repos().roots(); + assert!(roots.contains(&linked), "worktree below the project did not join the repository set: {roots:?}"); + assert_eq!(roots.len(), 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn removed_worktree_leaves_the_repository_set() { + let f = Fixture::new().await; + let linked = f.root.join("linked"); + vcs::vcs_add_worktree(&f.host, "main", None, linked.clone(), "main".into(), Some("topic".into())).await.unwrap(); + assert_eq!(f.project.repos().all().len(), 2); + vcs::vcs_remove_worktree(&f.host, "main", None, linked, false).await.unwrap(); + assert_eq!(f.project.repos().all().len(), 1); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn watcher_picks_up_a_worktree_added_below_the_project() { + let f = Fixture::new().await; + let linked = f.root.join("watched"); + git(&f.root, &["worktree", "add", "watched", "-b", "watched"]); + wait_for_repos(&f.project, 2).await; + let roots = f.project.repos().roots(); + assert!(roots.contains(&linked), "watcher did not discover the worktree added below the project: {roots:?}"); +} diff --git a/crates/ide-host/tests/workspace_edits.rs b/crates/ide-host/tests/workspace_edits.rs new file mode 100644 index 00000000..7bdc5c57 --- /dev/null +++ b/crates/ide-host/tests/workspace_edits.rs @@ -0,0 +1,177 @@ +use ide_core::{IdeEvent, SharedSink, WorkspaceEdit, error::ErrorKind}; +use ide_host::{Host, HostConfig, ops}; +use parking_lot::Mutex; +use serde_json::json; +use std::sync::Arc; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn preview_apply_and_grouped_undo_use_the_shared_host_commands() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("first.txt"); + let second = tmp.path().join("second.txt"); + for path in [&first, &second] { + std::fs::write(path, "old\n").unwrap(); + } + let events: Arc>> = Arc::default(); + let sink: SharedSink = { + let events = events.clone(); + Arc::new(move |event| events.lock().push(event)) + }; + let host = Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("local-data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |_| sink.clone()), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.open_light("main", &first).unwrap(); + let info = ops::doc::open(&host, "main", first.clone()).await.unwrap(); + let edit: WorkspaceEdit = serde_json::from_value(json!({ "files": [ + { "kind": "edit", "path": first, "version": info.version, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] }, + { "kind": "edit", "path": second, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] } + ] })).unwrap(); + let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit.clone()).await.unwrap(); + assert_eq!(preview.preconditions.len(), 2); + std::fs::write(&second, "own\n").unwrap(); + let error = ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap_err(); + assert_eq!(error.kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n"); + + let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit).await.unwrap(); + ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap(); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n"); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n"); + let id = events + .lock() + .iter() + .find_map(|event| match event { + IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap(); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n"); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "own\n"); + assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "old\n"); + ops::lsp::redo_workspace_edit(&host, "main", id).await.unwrap(); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n"); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n"); + host.clear_workspace_edit_history_on_exit(); + assert_eq!(ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap_err().kind, ErrorKind::NotFound); +} + +#[tokio::test(flavor = "current_thread")] +async fn contended_version_reads_do_not_block_the_async_worker() { + use ide_core::{LanguageId, Pos, Range, RequestId}; + use ide_workspace::documents::DocumentObserver; + use std::sync::mpsc; + use std::time::Duration; + + struct PausedWrite { + entered: Mutex>>, + release: Mutex>, + } + impl DocumentObserver for PausedWrite { + fn changed( + &self, + _: ide_core::DocId, + _: &std::path::Path, + _: u32, + _: &[ide_text::AppliedChange], + _: &dyn Fn() -> String, + ) { + if let Some(entered) = self.entered.lock().take() { + let _ = entered.send(()); + self.release.lock().recv_timeout(Duration::from_secs(10)).unwrap(); + } + } + } + + for request_kind in 0..5 { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("main.txt"); + std::fs::write(&file, "old").unwrap(); + let host = Arc::new(Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("local-data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(ide_core::NullSink)), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let project = host.open_light("main", &file).unwrap(); + let doc = project.documents.open(&file).unwrap().id; + assert_eq!(project.documents.snapshot(doc).unwrap().language, LanguageId::plain_text()); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = mpsc::channel(); + project.documents.add_observer(Arc::new(PausedWrite { + entered: Mutex::new(Some(entered_tx)), + release: Mutex::new(release_rx), + })); + let documents = project.documents.clone(); + let writer = std::thread::spawn(move || { + documents + .apply_workspace_edit( + &serde_json::from_value(json!({ "files": [{ + "kind": "edit", "path": file, "edits": [{ "range": { + "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } + }, "new_text": "new" }] + }] })) + .unwrap(), + ) + .unwrap(); + }); + entered_rx.await.unwrap(); + let (heartbeat_tx, heartbeat_rx) = mpsc::channel(); + // Always release the writer, even on the old blocking implementation. + let watchdog = std::thread::spawn(move || { + let responsive = heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok(); + release_tx.send(()).unwrap(); + responsive + }); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let _ = started_tx.send(()); + let action = + || serde_json::from_value(json!({ "title": "test", "isPreferred": false, "server": 1 })).unwrap(); + match request_kind { + 0 => { + let _ = ops::lsp::lsp_code_actions(&host, "main", doc, Range::point(Pos::new(0, 0)), vec![]).await; + } + 1 => { + let _ = ops::lsp::lsp_code_action_query( + &host, + "main", + RequestId(1), + doc, + Range::point(Pos::new(0, 0)), + vec![], + ) + .await; + } + 2 => { + let _ = ops::lsp::lsp_resolve_code_action(&host, "main", doc, action()).await; + } + 3 => { + let _ = ops::lsp::lsp_apply_code_action(&host, "main", doc, action()).await; + } + _ => { + let _ = + ops::lsp::lsp_rename(&host, "main", RequestId(1), doc, Pos::new(0, 0), "other".into()).await; + } + } + }); + started_rx.await.unwrap(); + let _ = heartbeat_tx.send(()); + task.await.unwrap(); + writer.join().unwrap(); + assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker"); + } +} diff --git a/crates/ide-host/tests/workspaces.rs b/crates/ide-host/tests/workspaces.rs index 86a8d9cd..cfa22542 100644 --- a/crates/ide-host/tests/workspaces.rs +++ b/crates/ide-host/tests/workspaces.rs @@ -22,6 +22,7 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-http/src/client.rs b/crates/ide-http/src/client.rs index 0623200e..b7b9dfa5 100644 --- a/crates/ide-http/src/client.rs +++ b/crates/ide-http/src/client.rs @@ -57,6 +57,16 @@ pub fn shared() -> IdeResult { Ok(client) } +/// `url`, with `http://` in front unless it starts with a scheme: a `://` later +/// on (`localhost:8080/login?next=http://…`) does not count. +pub(crate) fn with_scheme(url: &str) -> String { + let has_scheme = url.split_once("://").is_some_and(|(scheme, _)| { + scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + }); + if has_scheme { url.to_owned() } else { format!("http://{url}") } +} + /// HTTP Proxy › Check connection: GETs `url` through `proxy` (the settings as /// edited, not yet applied). Any response but `407` counts as connected. pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &CancellationToken) -> IdeResult { @@ -64,7 +74,7 @@ pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &Cancell return Err(IdeError::invalid(problem)); } let url = url.trim(); - let url = if url.contains("://") { url.to_owned() } else { format!("http://{url}") }; + let url = with_scheme(url); let client = build(proxy)?; let request = client.get(&url).timeout(Duration::from_secs(30)); let response = tokio::select! { diff --git a/crates/ide-http/src/exec.rs b/crates/ide-http/src/exec.rs index 47802fa4..963da518 100644 --- a/crates/ide-http/src/exec.rs +++ b/crates/ide-http/src/exec.rs @@ -1,6 +1,6 @@ //! Preparing (substitution, body includes) and executing requests. -use crate::client::describe; +use crate::client::{describe, with_scheme}; use crate::env::{Environments, Resolver}; use crate::parse::{BodyPart, RawRequest}; use ide_core::{CancellationToken, IdeError, IdeResult}; @@ -84,10 +84,8 @@ pub fn prepare( Some((_, host)) => format!("http://{host}{url}"), None => return Err(IdeError::invalid(format!("relative URL {url} needs a Host header"))), } - } else if !url.contains("://") { - format!("http://{url}") } else { - url + with_scheme(&url) }; Ok(PreparedRequest { name: raw.block.name.clone(), @@ -214,6 +212,20 @@ mod tests { assert_eq!(p.url, "http://localhost:9000/status"); } + #[test] + fn urls_without_a_scheme_default_to_http() { + let url = |text: &str| { + let f = parse(text); + prepare(&f.requests[0], &f.variables, &Environments::default(), None, None).unwrap().url + }; + assert_eq!(url("GET localhost:8080/health\n"), "http://localhost:8080/health"); + assert_eq!( + url("GET localhost:8080/login?next=http://localhost:3000/\n"), + "http://localhost:8080/login?next=http://localhost:3000/" + ); + assert_eq!(url("GET https://api.test/cb?u=http://x\n"), "https://api.test/cb?u=http://x"); + } + #[tokio::test] async fn executes_against_a_local_server() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index a42587cc..bd4fa6d5 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -15,9 +15,9 @@ mod syntax; pub use markers::marked_root; pub use registry::{FileTypes, LanguageRegistry}; pub use syntax::{ - CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, - ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, - SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility, + CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, + MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, + SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility, }; /// Parsed form of `languages/.toml`. Backend-only; the UI gets [`LanguageInfo`]. @@ -96,7 +96,9 @@ pub struct LanguageServerConfig { pub args: Vec, #[serde(default)] pub env: BTreeMap, - /// Files whose presence marks a workspace root, e.g. `Cargo.toml`. + /// Files whose presence marks a workspace root, e.g. `Cargo.toml`; + /// `file#table` requires the TOML file to contain that dotted table, e.g. + /// `pyproject.toml#tool.ruff`. #[serde(default)] pub root_markers: Vec, /// Serve only files below a root marker, not the whole project: a framework's diff --git a/crates/ide-language/src/markers.rs b/crates/ide-language/src/markers.rs index 832dc0c6..05eb9b0f 100644 --- a/crates/ide-language/src/markers.rs +++ b/crates/ide-language/src/markers.rs @@ -19,9 +19,13 @@ pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String]) best } -/// `dir` contains `marker`: a relative path, or a file name pattern with one -/// `*` (`*.csproj`). +/// `dir` contains `marker`: a relative path, a file name pattern with one +/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it +/// must contain (`pyproject.toml#tool.ruff`). fn has_marker(dir: &Path, marker: &str) -> bool { + if let Some((file, table)) = marker.split_once('#') { + return has_toml_table(&dir.join(file), table); + } let Some((prefix, suffix)) = marker.split_once('*') else { return dir.join(marker).exists(); }; @@ -30,9 +34,23 @@ fn has_marker(dir: &Path, marker: &str) -> bool { }) } +/// `file` parses as TOML and has the dotted `table` (`tool.ruff`). +fn has_toml_table(file: &Path, table: &str) -> bool { + let Ok(text) = std::fs::read_to_string(file) else { return false }; + let value = match toml::from_str::(&text) { + Ok(value) => value, + Err(e) => { + tracing::debug!("root marker {}: {e}", file.display()); + return false; + } + }; + table.split('.').try_fold(&value, |v, key| v.get(key)).is_some_and(toml::Value::is_table) +} + /// `path` is (or, after its creation or removal, was) a root marker of the /// directory above it. pub(crate) fn is_marker_path(path: &Path, marker: &str) -> bool { + let marker = marker.split_once('#').map_or(marker, |(file, _)| file); match marker.split_once('*') { None => path.ends_with(marker), Some((prefix, suffix)) => { @@ -74,6 +92,29 @@ mod tests { assert_eq!(marked_root(None, &file, &markers), Some(home.to_path_buf())); } + #[test] + fn toml_markers_need_their_table() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let file = root.join("svc/app/main.py"); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + let markers = vec!["ruff.toml".to_string(), "pyproject.toml#tool.ruff".to_string()]; + std::fs::write(root.join("pyproject.toml"), "[project]\nname = \"x\"\n[tool.black]\n").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), None); + std::fs::write(root.join("svc/pyproject.toml"), "[tool.ruff.lint]\nselect = [\"E\"]\n").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc"))); + // Outermost wins: an inline table counts; a value that is no table, or a + // file that does not parse, does not. + std::fs::write(root.join("pyproject.toml"), "tool = { ruff = { line-length = 100 } }\n").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), Some(root.to_path_buf())); + std::fs::write(root.join("pyproject.toml"), "tool = { ruff = 1 }\n").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc"))); + std::fs::write(root.join("pyproject.toml"), "[tool.ruff]\n[broken").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc"))); + assert!(is_marker_path(&root.join("svc/pyproject.toml"), "pyproject.toml#tool.ruff")); + assert!(!is_marker_path(&root.join("svc/setup.cfg"), "pyproject.toml#tool.ruff")); + } + #[test] fn patterns_match_file_names() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 31a9eda2..34caf951 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -8,6 +8,7 @@ use std::sync::Arc; /// Built-in definitions, embedded at compile time. Add new languages here. const BUILTIN: &[(&str, &str)] = &[ + ("asciidoc", include_str!("../../../languages/asciidoc.toml")), ("bat", include_str!("../../../languages/bat.toml")), ("c", include_str!("../../../languages/c.toml")), ("clojure", include_str!("../../../languages/clojure.toml")), @@ -15,11 +16,13 @@ const BUILTIN: &[(&str, &str)] = &[ ("cpp", include_str!("../../../languages/cpp.toml")), ("csharp", include_str!("../../../languages/csharp.toml")), ("css", include_str!("../../../languages/css.toml")), + ("d", include_str!("../../../languages/d.toml")), ("dart", include_str!("../../../languages/dart.toml")), ("diff", include_str!("../../../languages/diff.toml")), ("dockerfile", include_str!("../../../languages/dockerfile.toml")), ("dotenv", include_str!("../../../languages/dotenv.toml")), ("elixir", include_str!("../../../languages/elixir.toml")), + ("erb", include_str!("../../../languages/erb.toml")), ("erlang", include_str!("../../../languages/erlang.toml")), ("fsharp", include_str!("../../../languages/fsharp.toml")), ("gitattributes", include_str!("../../../languages/gitattributes.toml")), @@ -42,6 +45,7 @@ const BUILTIN: &[(&str, &str)] = &[ ("javascriptreact", include_str!("../../../languages/javascriptreact.toml")), ("json", include_str!("../../../languages/json.toml")), ("jsonc", include_str!("../../../languages/jsonc.toml")), + ("jsp", include_str!("../../../languages/jsp.toml")), ("julia", include_str!("../../../languages/julia.toml")), ("kotlin", include_str!("../../../languages/kotlin.toml")), ("latex", include_str!("../../../languages/latex.toml")), @@ -373,6 +377,7 @@ mod tests { /// `name`s exported by `@codemirror/language-data` or built into `src/editor/core/cm/languages.ts` /// (the UI resolves `ui-mode` against these). const UI_MODES: &[&str] = &[ + "AsciiDoc", "Batch", "C", "C++", @@ -517,6 +522,8 @@ mod tests { "MsGenny", "Vue", "Angular Template", + "ERB", + "JSP", ]; fn reg() -> LanguageRegistry { @@ -532,6 +539,12 @@ mod tests { let reg = reg(); assert_eq!(detect(®, "src/main.rs"), "rust"); assert_eq!(detect(®, "a/b.TS"), "typescript"); + for path in ["guide.adoc", "guide.asciidoc", "GUIDE.ADOC"] { + assert_eq!(detect(®, path), "asciidoc"); + } + let asciidoc = reg.get(&LanguageId::new("asciidoc")).unwrap(); + assert_eq!(asciidoc.ui_mode.as_deref(), Some("AsciiDoc")); + assert_eq!(asciidoc.line_comment.as_deref(), Some("//")); assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python"); assert_eq!(detect(®, "README"), "plaintext"); assert_eq!(detect(®, "unknown.zzz"), "plaintext"); @@ -544,7 +557,7 @@ mod tests { fn alternative_servers_are_fallbacks() { let reg = reg(); for (lang, servers) in [ - ("python", vec!["basedpyright", "pyright", "ty"]), + ("python", vec!["basedpyright", "pyright", "ty", "pyrefly"]), ("typescript", vec!["vtsls", "typescript-language-server"]), ] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); @@ -567,7 +580,7 @@ mod tests { }; // After the TypeScript fallback (an available server before it would hide // it); before the HTML server (hover, rename and symbols take the first). - assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server"]); + assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server", "oxlint"]); assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]); for lang in ["typescript", "html"] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); @@ -577,6 +590,50 @@ mod tests { } } + #[test] + fn oxlint_serves_js_files_only_below_its_config() { + let reg = reg(); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("apps/web/src")).unwrap(); + std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap(); + for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] { + let cfg = reg.get(&LanguageId::new(lang)).unwrap(); + let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap(); + assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice())); + assert!(oxlint.require_root_marker && !oxlint.fallback, "{lang}"); + let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &oxlint.root_markers); + assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}"); + assert_eq!(served("apps/api/main.ts"), None, "{lang}"); + } + } + + #[test] + fn ruff_serves_python_files_only_below_its_config() { + let reg = reg(); + let cfg = reg.get(&LanguageId::new("python")).unwrap(); + let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect(); + // After the fallbacks: hover, rename and symbols stay with the type checker. + assert_eq!(names, ["basedpyright", "pyright", "ty", "pyrefly", "ruff"]); + let ruff = cfg.language_servers.iter().find(|s| s.name == "ruff").unwrap(); + assert_eq!((ruff.command.as_str(), ruff.args.as_slice()), ("ruff", ["server".to_string()].as_slice())); + assert!(ruff.require_root_marker && !ruff.fallback); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for dir in ["lint", "dot", "pyproject", "plain"] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + std::fs::write(root.join("lint/ruff.toml"), "").unwrap(); + std::fs::write(root.join("dot/.ruff.toml"), "").unwrap(); + std::fs::write(root.join("pyproject/pyproject.toml"), "[tool.ruff]\nline-length = 100\n").unwrap(); + std::fs::write(root.join("plain/pyproject.toml"), "[project]\nname = \"plain\"\n").unwrap(); + let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &ruff.root_markers); + for dir in ["lint", "dot", "pyproject"] { + assert_eq!(served(&format!("{dir}/pkg/main.py")), Some(root.join(dir)), "{dir}"); + } + assert_eq!(served("plain/main.py"), None); + } + #[test] fn html_of_angular_projects_is_an_angular_template() { let reg = reg(); @@ -723,6 +780,7 @@ mod tests { ("AppDelegate.m", "objective-c"), ("a.rb", "ruby"), ("Gemfile", "ruby"), + ("show.html.erb", "erb"), ("index.php", "php"), ("init.lua", "lua"), ("main.dart", "dart"), @@ -747,6 +805,8 @@ mod tests { ("a.less", "less"), ("App.vue", "vue"), ("App.svelte", "svelte"), + ("index.jsp", "jsp"), + ("WEB-INF/tags/card.tag", "jsp"), ("package.json", "json"), ("tsconfig.json", "jsonc"), ("tsconfig.app.json", "jsonc"), diff --git a/crates/ide-language/src/syntax/configuration.rs b/crates/ide-language/src/syntax/configuration.rs new file mode 100644 index 00000000..4a1a081f --- /dev/null +++ b/crates/ide-language/src/syntax/configuration.rs @@ -0,0 +1,320 @@ +//! Source ranges for Spring-style configuration keys. This is a syntax index, +//! not a YAML evaluator: aliases, merge keys and runtime profile precedence are +//! intentionally left to the YAML server / application. +use super::lines::{LineIndex, byte_at}; +use super::{ParsedTree, Syntax, TreeError}; +use ide_core::{LanguageId, Pos, Range}; +use tree_sitter::Node; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConfigurationKey { + pub name: String, + pub range: Range, +} + +impl Syntax { + pub fn configuration_keys( + &self, + language: &LanguageId, + text: &str, + cancelled: &dyn Fn() -> bool, + ) -> Result, TreeError> { + if language.as_str() == "properties" { + return Ok(properties(text)); + } + self.with_parsed(language, text, cancelled, |tree| tree.configuration_keys(cancelled)) + } +} + +/// Spring's property lookups whose first argument names a configuration key. +const PROPERTY_LOOKUPS: [&str; 2] = ["getProperty", "getRequiredProperty"]; + +impl Syntax { + /// Whether `pos` in Java `text` lies where a configuration key can be written: + /// inside the arguments of a `@Value` annotation or of a `getProperty` / + /// `getRequiredProperty` call. Everywhere else Go to Definition is ordinary + /// Java navigation and need not consult the configuration index. A file the + /// grammar cannot parse answers false. + pub fn java_configuration_site(&self, text: &str, pos: Pos, cancelled: &dyn Fn() -> bool) -> bool { + let Some(at) = byte_at(text, pos) else { return false }; + self.with_parsed(&LanguageId::new("java"), text, cancelled, |tree| { + let mut node = tree.tree.root_node().descendant_for_byte_range(at, at); + while let Some(n) = node { + let Some(parent) = n.parent() else { break }; + let callee = parent.child_by_field_name("name").map(|name| &text[name.byte_range()]); + match (n.kind(), parent.kind(), callee) { + ("annotation_argument_list", "annotation", Some(name)) => { + return name.rsplit('.').next() == Some("Value"); + } + ("argument_list", "method_invocation", Some(name)) if PROPERTY_LOOKUPS.contains(&name) => { + return true; + } + // Keys are expressions; nothing above a body or declaration can hold one. + (_, "block" | "class_body" | "interface_body" | "program", _) => return false, + _ => node = Some(parent), + } + } + false + }) + .unwrap_or(false) + } +} + +impl ParsedTree<'_> { + pub fn configuration_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec { + let mut out = Vec::new(); + if self.grammar == "yaml" { + yaml(self.tree.root_node(), "", self.text, &LineIndex::new(self.text), &mut out, cancelled, 0); + } + out + } +} + +fn yaml( + node: Node<'_>, + prefix: &str, + text: &str, + lines: &LineIndex<'_>, + out: &mut Vec, + cancelled: &dyn Fn() -> bool, + depth: usize, +) { + if depth > 64 || cancelled() || out.len() >= 20_000 { + return; + } + match node.kind() { + "block_mapping_pair" | "flow_pair" => { + let Some(key) = node.child_by_field_name("key") else { return }; + let raw = &text[key.byte_range()]; + if raw.contains('\n') { + return; + } + let name = if raw.starts_with('\'') && raw.ends_with('\'') { + raw[1..raw.len() - 1].replace("''", "'") + } else if raw.starts_with('"') { + // JSON escapes cover ordinary quoted configuration keys. Unsupported + // YAML-only escapes are skipped rather than indexed under a wrong key. + let Ok(name) = serde_json::from_str::(raw) else { return }; + name + } else { + if raw.contains(['\n', '{', '[', '*', '&', '!']) || raw == "<<" { + return; + } + raw.to_owned() + }; + if name.is_empty() { + return; + } + let name = if prefix.is_empty() { + name + } else if name.starts_with('[') { + format!("{prefix}{name}") + } else { + format!("{prefix}.{name}") + }; + if name.len() > 1024 { + return; + } + out.push(ConfigurationKey { name: name.clone(), range: lines.range(key.start_byte(), key.end_byte()) }); + if let Some(value) = node.child_by_field_name("value") { + yaml(value, &name, text, lines, out, cancelled, depth + 1); + } + } + "block_sequence" | "flow_sequence" => { + let mut cursor = node.walk(); + for (i, child) in node.named_children(&mut cursor).filter(|n| n.kind() != "comment").enumerate() { + yaml(child, &format!("{prefix}[{i}]"), text, lines, out, cancelled, depth + 1); + } + } + // Do not descend into scalars, anchors or aliases. + "stream" + | "document" + | "block_node" + | "flow_node" + | "block_mapping" + | "flow_mapping" + | "block_sequence_item" => { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + yaml(child, prefix, text, lines, out, cancelled, depth + 1); + } + } + _ => {} + } +} + +fn properties(text: &str) -> Vec { + let lines = LineIndex::new(text); + let mut out = Vec::new(); + let mut logical = String::new(); + let mut offsets = Vec::new(); + let mut base = 0; + for line in text.split_inclusive('\n') { + let physical = line.trim_end_matches(['\r', '\n']); + let start = physical.len() - physical.trim_start_matches([' ', '\t', '\u{c}']).len(); + let slice = &physical[start..]; + if logical.is_empty() && (slice.is_empty() || slice.starts_with(['#', '!'])) { + base += line.len(); + continue; + } + let continued = slice.bytes().rev().take_while(|b| *b == b'\\').count() % 2 == 1; + let end = physical.len() - usize::from(continued); + for (i, ch) in physical[start..end].char_indices() { + offsets.extend((0..ch.len_utf8()).map(|byte| base + start + i + byte)); + logical.push(ch); + } + if !continued || base + line.len() == text.len() { + let mut escaped = false; + let key_end = logical + .char_indices() + .find_map(|(i, ch)| { + if escaped { + escaped = false; + return None; + } + if ch == '\\' { + escaped = true; + return None; + } + matches!(ch, '=' | ':' | ' ' | '\t' | '\u{c}').then_some(i) + }) + .unwrap_or(logical.len()); + if key_end > 0 + && key_end <= 1024 + && let Some(name) = unescape_property(&logical[..key_end]) + { + out.push(ConfigurationKey { name, range: lines.range(offsets[0], offsets[key_end - 1] + 1) }); + } + logical.clear(); + offsets.clear(); + } + base += line.len(); + } + out +} + +fn unescape_property(raw: &str) -> Option { + let mut chars = raw.chars(); + let mut out = String::new(); + while let Some(ch) = chars.next() { + if ch != '\\' { + out.push(ch); + continue; + } + match chars.next()? { + 'u' => { + let hex: String = chars.by_ref().take(4).collect(); + if hex.len() != 4 { + return None; + } + out.push(char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?); + } + 't' => out.push('\t'), + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 'f' => out.push('\u{c}'), + ch => out.push(ch), + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn java_configuration_sites() { + let text = r#"package a; +import org.springframework.beans.factory.annotation.Value; +class A { + @Value("${app.port:80}") int port; + @org.springframework.beans.factory.annotation.Value(value = Keys.NAME) String name; + @Values("x") int other; + @Override public String toString() { + String v = env.getProperty("app.host", "h") + env.getRequiredProperty(Keys.USER); + return helper.getValue("😀 app.port") + String.valueOf(port) + env.getProperty("k").trim(); + } +} +"#; + let syntax = Syntax::new(); + let at = |needle: &str, offset: u32| { + let line = text.lines().position(|l| l.contains(needle)).unwrap(); + let byte = text.lines().nth(line).unwrap().find(needle).unwrap(); + let col = text.lines().nth(line).unwrap()[..byte].encode_utf16().count() as u32 + offset; + syntax.java_configuration_site(text, Pos::new(line as u32, col), &|| false) + }; + // @Value arguments (literal, qualified annotation, constant key). + assert!(at("${app.port", 2)); + assert!(at("Keys.NAME", 6)); + // Lookup arguments, including a constant and the default value. + assert!(at("\"app.host\"", 3)); + assert!(at("\"h\"", 1)); + assert!(at("Keys.USER", 1)); + assert!(at("\"k\"", 1)); + // Ordinary Java: names, other annotations, other calls, the lookup's own name and receiver. + assert!(!at("@Value(\"$", 2)); + assert!(!at("int port", 4)); + assert!(!at("@Values", 9)); + assert!(!at("@Override", 2)); + assert!(!at("getProperty(\"app", 3)); + assert!(!at("env.getProperty(\"app", 1)); + assert!(!at("app.port\")", 1)); + assert!(!at("valueOf", 2)); + assert!(!at(".trim", 2)); + // Out of range and non-Java input. + assert!(!syntax.java_configuration_site(text, Pos::new(99, 0), &|| false)); + assert!(!syntax.java_configuration_site("", Pos::new(0, 0), &|| false)); + } + #[test] + fn yaml_structures_and_utf16() { + let text = "app:\n 'time-out': 1\n hosts:\n - url: a\n - {url: b}\n map: {east: {size: 2}}\n---\n\"app.name\": ok\n'😀': {port: 3}\n"; + let keys = Syntax::new().configuration_keys(&LanguageId::new("yaml"), text, &|| false).unwrap(); + let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect(); + assert_eq!( + names, + [ + "app", + "app.time-out", + "app.hosts", + "app.hosts[0].url", + "app.hosts[1].url", + "app.map", + "app.map.east", + "app.map.east.size", + "app.name", + "😀", + "😀.port" + ] + ); + assert_eq!(keys.last().unwrap().range.start.col, 7); + } + #[test] + fn aliases_and_values_are_not_configuration_keys() { + let keys = Syntax::new() + .configuration_keys( + &LanguageId::new("yaml"), + "base: &base {size: 1}\napp:\n <<: *base\n text: |\n fake: key\n", + &|| false, + ) + .unwrap(); + assert_eq!(keys.iter().map(|k| k.name.as_str()).collect::>(), ["base", "base.size", "app", "app.text"]); + } + #[test] + fn properties_unicode_ranges_and_eof_continuation() { + let keys = properties("app.中文=1\nlast\\\n"); + assert_eq!(keys[0].range.end.col, 6); + assert_eq!(keys[1].name, "last"); + } + #[test] + fn properties_escapes_continuations_and_comments() { + let keys = properties( + "# comment\r\napp.time\\\n -out : 1\napp\\.name=ok\napp.\\u0061=2\nspace\\ key value\n!ignored=x\n", + ); + assert_eq!( + keys.iter().map(|k| k.name.as_str()).collect::>(), + ["app.time-out", "app.name", "app.a", "space key"] + ); + assert_eq!(keys[0].range.end.line, 2); + } +} diff --git a/crates/ide-language/src/syntax/lines.rs b/crates/ide-language/src/syntax/lines.rs index 5b8de206..6fd0b190 100644 --- a/crates/ide-language/src/syntax/lines.rs +++ b/crates/ide-language/src/syntax/lines.rs @@ -86,6 +86,16 @@ pub(crate) fn utf16_len(s: &str) -> u32 { if s.is_ascii() { s.len() as u32 } else { s.chars().map(|c| c.len_utf16() as u32).sum() } } +/// Byte offset of `pos` (line, UTF-16 column) in `text`; None past the last line. +pub(crate) fn byte_at(text: &str, pos: Pos) -> Option { + let mut start = 0; + for _ in 0..pos.line { + start += text[start..].find('\n')? + 1; + } + let line_end = text[start..].find('\n').map_or(text.len(), |i| start + i); + Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize)) +} + pub(crate) fn utf16_to_byte(s: &str, offset: usize) -> usize { let mut units = 0; for (i, c) in s.char_indices() { diff --git a/crates/ide-language/src/syntax/mod.rs b/crates/ide-language/src/syntax/mod.rs index 5e22c169..e961eb35 100644 --- a/crates/ide-language/src/syntax/mod.rs +++ b/crates/ide-language/src/syntax/mod.rs @@ -1,6 +1,7 @@ //! Tree-sitter driven structure: outline, folding, symbol tags, selection ranges, highlighting. mod cache; +mod configuration; mod folding; mod grammars; mod highlight; @@ -12,6 +13,7 @@ mod web; use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag}; pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats}; +pub use configuration::ConfigurationKey; use grammars::{SPECS, Spec}; pub use highlight::{SyntaxHighlightKind, SyntaxHighlights}; use ide_core::{LanguageId, OutlineItem, Pos, Range}; @@ -199,11 +201,11 @@ impl Syntax { /// The grammar web features parse `lang` with: its own for HTML and CSS, the /// CSS grammar for SCSS / Less (selectors and custom properties parse alike), - /// HTML for Vue / Svelte single-file components. + /// HTML for Vue / Svelte single-file components and JSP pages. fn web_grammar(&self, lang: &LanguageId) -> Option<&Grammar> { let name = match lang.as_str() { "scss" | "less" => "css", - "vue" | "svelte" => "html", + "vue" | "svelte" | "jsp" => "html", _ => self.grammar(lang).map(|g| g.spec.name)?, }; web::is_web_grammar(name).then(|| self.grammar(&LanguageId::new(name))).flatten() diff --git a/crates/ide-language/src/syntax/web.rs b/crates/ide-language/src/syntax/web.rs index 73c9896d..88a28757 100644 --- a/crates/ide-language/src/syntax/web.rs +++ b/crates/ide-language/src/syntax/web.rs @@ -6,7 +6,7 @@ //! `