diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index d3188b77a..52e087f08 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -24,8 +24,10 @@ fn tools(host: &Host, window: &str) -> IdeResult { .collect(); // Godot is usually an app bundle / Flatpak outside the search path. let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p)); + let runtime = ide_sdk::apply::tool_install_environment(&root, &settings); let work_dir = (!root.as_os_str().is_empty()).then_some(root); Ok(Tools::new(&data) + .with_runtime(runtime.commands, runtime.env) .with_custom_paths(custom) .with_found_paths(found.into_iter().collect()) .with_work_dir(work_dir)) diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 98a1fbf44..527154bcf 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -130,6 +130,34 @@ pub(crate) fn goroot(root: &Path, s: &Settings) -> Option { .or_else(|| pinned::pinned_install(root, Tool::Go).and_then(|(dir, _)| pinned::goroot_in_install(&dir))) } +/// Runtime choices for package-manager based IDE tool installs. +#[derive(Default)] +pub struct ToolInstallEnvironment { + pub commands: std::collections::BTreeMap, + pub env: std::collections::BTreeMap, +} + +/// Uses the same configured / project-pinned SDKs as Run and language servers. +/// Build tags and target OS settings intentionally do not apply to host tools. +pub fn tool_install_environment(root: &Path, s: &Settings) -> ToolInstallEnvironment { + let mut runtime = ToolInstallEnvironment::default(); + if let Some(go) = goroot(root, s) { + runtime.commands.insert("go".into(), go.join("bin").join(exe("go"))); + runtime.env.insert("GOROOT".into(), go.to_string_lossy().into_owned()); + } + if let Some(node) = node_dir(root, s) { + let node = setting_path(root, &s.languages.node.interpreter).unwrap_or_else(|| node.join(exe("node"))); + runtime.commands.insert("node".into(), node); + } + if let Some(ruby) = ruby_interpreter(root, s) { + runtime.commands.insert("ruby".into(), ruby); + } + if let Some(python) = python_interpreter(root, s) { + runtime.commands.insert("python".into(), python); + } + runtime +} + /// A JDK setting: a JDK home, its `bin` or its `bin/java`. fn jdk_setting(root: &Path, value: &str) -> Option> { let p = setting_path(root, value)?; diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 48aa2dd80..f30e5b2a9 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -1,6 +1,6 @@ use crate::apply::{ apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter, - ruff_configuration, terminal_activation, tsdk, without_derived, + ruff_configuration, terminal_activation, tool_install_environment, tsdk, without_derived, }; use crate::python::parse_pip_list; use crate::{bin_dir, exe, venv_of}; @@ -2246,3 +2246,25 @@ fn plugin_interpreter_targets_route_all_php_node_command_families() { assert_eq!(config.extras.target.as_deref(), Some("")); } } + +#[test] +fn tool_installs_resolve_project_relative_sdks_and_leave_path_fallback_unconfigured() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut s = Settings::default(); + let defaults = tool_install_environment(root, &s); + assert!(!defaults.commands.contains_key("go")); + assert!(!defaults.commands.contains_key("node")); + assert!(!defaults.commands.contains_key("python")); + assert!(!defaults.env.contains_key("GOROOT")); + s.languages.go.goroot = "go-sdk".into(); + s.languages.node.interpreter = "node-sdk/bin/node".into(); + s.languages.python.interpreter = "python-sdk/bin/python3.14".into(); + s.languages.ruby.interpreter = "ruby-sdk/bin/ruby".into(); + let runtime = tool_install_environment(root, &s); + assert_eq!(runtime.commands["go"], root.join("go-sdk/bin").join(if cfg!(windows) { "go.exe" } else { "go" })); + assert_eq!(runtime.env["GOROOT"], root.join("go-sdk").to_string_lossy()); + assert_eq!(runtime.commands["node"], root.join("node-sdk/bin/node")); + assert_eq!(runtime.commands["python"], root.join("python-sdk/bin/python3.14")); + assert_eq!(runtime.commands["ruby"], root.join("ruby-sdk/bin/ruby")); +} diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index dbdddfa5b..d34ee6a83 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -421,6 +421,7 @@ pub fn managed_bundle(app_data: &Path, name: &str) -> Option { } /// Installer and detector over one tools directory. +#[derive(Clone)] pub struct Tools { root: PathBuf, /// Search path for system tools (excludes our own dirs). @@ -432,6 +433,8 @@ pub struct Tools { custom: BTreeMap, /// Executables found outside the search path (app bundles), by command. found: BTreeMap, + runtime_commands: BTreeMap, + runtime_env: BTreeMap, version_timeout: Duration, /// The project the tools are used for: tools that install into "the active runtime" (gems) run here, /// so a version manager picks the project's Ruby. @@ -456,11 +459,41 @@ impl Tools { platform, custom: BTreeMap::new(), found: BTreeMap::new(), + runtime_commands: BTreeMap::new(), + runtime_env: BTreeMap::new(), version_timeout: Duration::from_secs(4), work_dir: None, } } + /// Applies the project's SDKs to prerequisite lookup, probes and installer children. + pub fn with_runtime(mut self, commands: BTreeMap, env: BTreeMap) -> Self { + self.runtime_commands = commands; + self.runtime_env = env; + self + } + + fn for_method(&self, method: Method) -> Self { + let commands: &[&str] = match method { + Method::Go { .. } => &["go"], + Method::Npm { .. } => &["node"], + Method::Gem { .. } | Method::GemActive { .. } => &["ruby"], + Method::PipVenv { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => &["python"], + _ => &[], + }; + let mut runtime = self.clone(); + runtime.runtime_commands.retain(|name, _| commands.contains(&name.as_str())); + runtime.runtime_env.retain(|name, _| name == "GOROOT" && commands.contains(&"go")); + let dirs: Vec = + runtime.runtime_commands.values().filter_map(|p| p.parent().map(Path::to_path_buf)).collect(); + runtime.system_path = + std::env::join_paths(dirs.iter().cloned().chain(std::env::split_paths(&self.system_path))) + .unwrap_or_else(|_| self.system_path.clone()); + runtime.child_path = std::env::join_paths(dirs.iter().cloned().chain(std::env::split_paths(&self.child_path))) + .unwrap_or_else(|_| self.child_path.clone()); + runtime + } + /// Reports the executables chosen in settings (command → path) as the tools' location. pub fn with_custom_paths(mut self, custom: BTreeMap) -> Self { self.custom = custom; @@ -493,6 +526,9 @@ impl Tools { } fn which(&self, cmd: &str) -> Option { + if let Some(path) = self.runtime_commands.get(cmd) { + return path.is_file().then(|| path.clone()); + } ide_core::tool_path::find_command(cmd, &self.system_path, &self.root) } @@ -502,7 +538,7 @@ impl Tools { fn command(&self, program: impl AsRef) -> Command { let mut cmd = Command::new(program); - ide_core::process::prepare(&mut cmd).env("PATH", &self.child_path); + ide_core::process::prepare(&mut cmd).envs(&self.runtime_env).env("PATH", &self.child_path); cmd } @@ -524,6 +560,9 @@ impl Tools { /// Aliases that only open the Store unless the Store's Python is installed, so they count /// only when they run. fn python(&self) -> Option { + if self.runtime_commands.contains_key("python") { + return self.which("python"); + } if self.platform.os != Os::Windows { return self.which_any(&["python3", "python"]); } @@ -664,6 +703,13 @@ impl Tools { } fn info_for(&self, name: &str, usage: Option<&Usage>) -> Option { + match registry::find(name) { + Some(spec) => self.for_method(spec.method).info_for_runtime(name, usage), + None => self.info_for_runtime(name, usage), + } + } + + fn info_for_runtime(&self, name: &str, usage: Option<&Usage>) -> Option { let spec = registry::find(name); let languages = usage.map(|u| u.languages.clone()).unwrap_or_default(); let install_hint = usage.and_then(|u| u.install_hint.clone()); @@ -868,6 +914,19 @@ impl Tools { langs: &[LanguageConfig], cancel: &CancellationToken, emit: Emit, + ) -> IdeResult { + match registry::find(name) { + Some(spec) => self.for_method(spec.method).install_for_runtime(name, langs, cancel, emit), + None => self.install_for_runtime(name, langs, cancel, emit), + } + } + + fn install_for_runtime( + &self, + name: &str, + langs: &[LanguageConfig], + cancel: &CancellationToken, + emit: Emit, ) -> IdeResult { let spec = registry::find(name).ok_or_else(|| { let hint = usages(langs).get(name).and_then(|u| u.install_hint.clone()); @@ -1130,6 +1189,9 @@ impl Tools { if let Some(uv) = self.which("uv") { let mut c = self.command(&uv); c.arg("venv").arg(&venv); + if let Some(python) = self.runtime_commands.get("python") { + c.arg("--python").arg(python); + } run_streamed(c, cancel, emit)?; let mut c = self.command(&uv); c.args(["pip", "install", "--python"]).arg(&python).arg(package); @@ -1371,6 +1433,13 @@ impl Tools { /// Removes a tool the IDE installed. pub fn uninstall(&self, name: &str, langs: &[LanguageConfig]) -> IdeResult { + match registry::find(name) { + Some(spec) => self.for_method(spec.method).uninstall_for_runtime(name, langs), + None => self.uninstall_for_runtime(name, langs), + } + } + + fn uninstall_for_runtime(&self, name: &str, langs: &[LanguageConfig]) -> IdeResult { let spec = registry::find(name).ok_or_else(|| IdeError::not_found(format!("unknown tool `{name}`")))?; // Uninstalling mid-install would tear down the directory under the running installer. if INSTALLING.lock().as_ref().is_some_and(|m| m.contains_key(name)) { diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 1d606fe21..6e8b1d22b 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -438,12 +438,15 @@ esac"#, #[test] fn npm_install_uses_tools_prefix() { let env = Env::new(); + let sdk = env._tmp.path().join("selected-sdk/bin"); + fs::create_dir_all(&sdk).unwrap(); let log = env.root.parent().unwrap().join("npm-args.txt"); env.script( - &env.sys, + &sdk, "npm", &format!( - r#"echo "$@" > '{log}' + r#"node --version >/dev/null || exit 98 +echo "$@" > '{log}' prefix=""; while [ $# -gt 0 ]; do [ "$1" = "--prefix" ] && prefix="$2"; shift; done /bin/mkdir -p "$prefix/node_modules/.bin" "$prefix/node_modules/bash-language-server" printf '#!/bin/sh\necho 5.4.0\n' > "$prefix/node_modules/.bin/bash-language-server" @@ -453,7 +456,14 @@ echo "added 1 package""#, log = log.display() ), ); - let info = env.tools().install("bash-language-server", &langs(), &CancellationToken::new(), &|_| {}).unwrap(); + env.script(&sdk, "node", "exit 0"); + env.script(&env.sys, "node", "exit 99"); + env.script(&env.sys, "npm", "exit 99"); + let tools = env.tools().with_runtime( + BTreeMap::from([("node".into(), sdk.join("node")), ("ruby".into(), env.sys.join("ruby"))]), + BTreeMap::new(), + ); + let info = tools.install("bash-language-server", &langs(), &CancellationToken::new(), &|_| {}).unwrap(); assert_eq!(info.status, ToolStatus::Installed); assert_eq!(info.version.as_deref(), Some("5.4.0")); let args = fs::read_to_string(&log).unwrap(); @@ -581,14 +591,19 @@ fn elixir_ls_needs_elixir_and_lists_its_debugger() { #[test] fn gem_install_wraps_stubs_with_gem_home() { let env = Env::new(); + let sdk = env._tmp.path().join("selected-sdk/bin"); + fs::create_dir_all(&sdk).unwrap(); // `gem install --no-document --install-dir --bindir solargraph` env.script( - &env.sys, + &sdk, "gem", r#"home="$4"; bin="$6"; /bin/mkdir -p "$home/gems/solargraph-0.50.0" "$bin" printf '#!/bin/sh\necho "$GEM_HOME"\n' > "$bin/solargraph"; /bin/chmod +x "$bin/solargraph""#, ); - let tools = env.tools(); + env.script(&sdk, "ruby", "exit 0"); + env.script(&env.sys, "ruby", "exit 99"); + env.script(&env.sys, "gem", "exit 99"); + let tools = env.tools().with_runtime(BTreeMap::from([("ruby".into(), sdk.join("ruby"))]), BTreeMap::new()); let info = tools.install("solargraph", &langs(), &CancellationToken::new(), &|_| {}).unwrap(); assert_eq!(info.status, ToolStatus::Installed); assert_eq!(info.version.as_deref(), Some("0.50.0")); @@ -602,16 +617,18 @@ printf '#!/bin/sh\necho "$GEM_HOME"\n' > "$bin/solargraph"; /bin/chmod +x "$bin/ #[test] fn ruby_lsp_installs_into_the_project_ruby_not_a_private_gem_home() { let env = Env::new(); + let sdk = env._tmp.path().join("selected-sdk/bin"); + fs::create_dir_all(&sdk).unwrap(); let project = env._tmp.path().join("project"); fs::create_dir_all(&project).unwrap(); let marker = env._tmp.path().join("installed"); // Homebrew's Ruby: the gem bindir is not on the search path; only the Ruby knows it. let bindir = env._tmp.path().join("gems/bin"); fs::create_dir_all(&bindir).unwrap(); - env.script(&env.sys, "ruby", &format!("echo '{}'", bindir.display())); + env.script(&sdk, "ruby", &format!("echo '{}'", bindir.display())); // `gem list --exact ruby-lsp` knows the gem once `gem install ruby-lsp` ran, in the cwd it was given. env.script( - &env.sys, + &sdk, "gem", &format!( r#"case "$1" in @@ -623,7 +640,10 @@ esac"#, b = bindir.display() ), ); - let tools = env.tools().with_work_dir(Some(project.clone())); + env.script(&env.sys, "ruby", "exit 99"); + env.script(&env.sys, "gem", "exit 99"); + let tools = env.tools().with_runtime(BTreeMap::from([("ruby".into(), sdk.join("ruby"))]), BTreeMap::new()); + let tools = tools.with_work_dir(Some(project.clone())); assert_eq!(find(&tools.list(&langs()), "ruby-lsp").status, ToolStatus::Missing); let info = tools.install("ruby-lsp", &langs(), &CancellationToken::new(), &|_| {}).unwrap(); assert_eq!(info.status, ToolStatus::OnPath); @@ -800,3 +820,88 @@ fn python_probes_run_in_an_empty_private_directory() { let dir = env.root.join(".python-cwd"); assert_eq!(out.trim(), dir.canonicalize().unwrap().to_string_lossy()); } + +#[cfg(unix)] +#[test] +fn go_installers_use_selected_sdk_instead_of_broken_mise_shim() { + for spec in registry::TOOLS.iter().filter(|s| matches!(s.method, registry::Method::Go { .. })) { + let env = Env::new(); + env.script(&env.sys, "go", "echo 'mise ERROR No version is set for shim: go' >&2; exit 1"); + let sdk = env._tmp.path().join("selected go"); + let log = env._tmp.path().join("go.log"); + env.script( + &sdk.join("bin"), + "go", + &format!( + r#" +[ "$GOROOT" = '{sdk}' ] || exit 99 +echo "$@" >> '{log}' +case "$1" in +install) printf '#!/bin/sh\necho 1.2.3\n' > "$GOBIN/{command}"; /bin/chmod +x "$GOBIN/{command}" ;; +version) echo 'mod example.com/tool v1.2.3' ;; +esac +"#, + sdk = sdk.display(), + log = log.display(), + command = spec.commands[0] + ), + ); + let tools = env.tools().with_runtime( + BTreeMap::from([("go".into(), sdk.join("bin/go"))]), + BTreeMap::from([("GOROOT".into(), sdk.to_string_lossy().into_owned())]), + ); + let info = tools.install(spec.name, &langs(), &CancellationToken::new(), &|_| {}).unwrap(); + assert_eq!(info.status, ToolStatus::Installed, "{}", spec.name); + assert_eq!(info.version.as_deref(), Some("1.2.3")); + let log = fs::read_to_string(log).unwrap(); + assert!(log.contains("install -v ") && log.contains("version -m"), "{log}"); + tools.uninstall(spec.name, &langs()).unwrap(); + assert!( + env.tools() + .install(spec.name, &langs(), &CancellationToken::new(), &|_| {}) + .unwrap_err() + .message + .contains("failed") + ); + } +} + +#[cfg(unix)] +#[test] +fn selected_python_is_used_for_pip_and_uv_even_with_a_versioned_filename() { + let env = Env::new(); + env.script(&env.sys, "python3", "exit 99"); + let sdk = env._tmp.path().join("python-sdk"); + let pip_log = env._tmp.path().join("pip.log"); + env.script( + &sdk, + "python3.14", + &format!( + r#" +echo "$@" >> '{log}' +[ "$1" = "-c" ] && exit 1 +while [ $# -gt 0 ]; do + if [ "$1" = "--target" ]; then + /bin/mkdir -p "$2/debugpy" "$2/debugpy-1.2.3.dist-info" + /usr/bin/touch "$2/debugpy/__init__.py" + exit 0 + fi + shift +done +exit 1 +"#, + log = pip_log.display() + ), + ); + let tools = env.tools().with_runtime(BTreeMap::from([("python".into(), sdk.join("python3.14"))]), BTreeMap::new()); + assert_eq!(tools.python(), Some(sdk.join("python3.14"))); + let info = tools.install("debugpy", &langs(), &CancellationToken::new(), &|_| {}).unwrap(); + assert_eq!(info.status, ToolStatus::Installed); + assert_eq!(info.version.as_deref(), Some("1.2.3")); + assert!(fs::read_to_string(pip_log).unwrap().contains("-m pip install")); + let log = env._tmp.path().join("uv.log"); + env.script(&env.sys, "uv", &format!("echo \"$@\" >> '{}'; exit 1", log.display())); + let _ = tools.install("ruff", &langs(), &CancellationToken::new(), &|_| {}); + let args = fs::read_to_string(log).unwrap(); + assert!(args.contains(&format!("--python {}", sdk.join("python3.14").display())), "{args}"); +} diff --git a/src/features/run/session.test.tsx b/src/features/run/session.test.tsx index 0b43a7dc2..e596d3b44 100644 --- a/src/features/run/session.test.tsx +++ b/src/features/run/session.test.tsx @@ -17,6 +17,7 @@ import { ConsoleView, type ConsoleInput } from "./ConsoleView"; import { buildRows, mnemonics } from "./ConfigChooser"; import { activate } from "./index"; import { + onRunExited, closeSessionTab, resetRuns, runtime, @@ -322,3 +323,24 @@ describe("plugin test selection launches", () => { } }); }); + +describe("Go SDK recovery", () => { + it.each([ + ["mise ERROR No version is set for shim: go", "go", 1, true], + ["No version is set for shim: go", "shell", 1, false], + ["compile error", "go", 1, false], + ["No version is set for shim: go", "go", 0, false], + ] as const)("offers Go settings for %s (%s, %s)", async (text, kind, code, expected) => { + const notify = vi.fn(); + provideNotificationService({ notify }); + const session = (await startRun(config("go", { kind })))!; + runtime(session.id)!.console.append({ kind: "stderr", text: text.slice(0, 12) }); + runtime(session.id)!.console.append({ kind: "stderr", text: text.slice(12) + "\n" }); + const runId = 4321 as NonNullable; + useRun.setState((s) => ({ sessions: s.sessions.map((x) => ({ ...x, runId })) })); + onRunExited(runId, code); + await vi.waitFor(() => expect(useRun.getState().sessions[0]!.status).toBe("finished")); + expect(notify).toHaveBeenCalledTimes(expected ? 1 : 0); + if (expected) expect(notify.mock.calls[0]![0].actions[0].label).toBe("Go Settings…"); + }); +}); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index e8559d149..1398aff90 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -1103,6 +1103,21 @@ export function testReportEvents(reports: readonly TestCaseReport[]): TestEvent[ function notifyExit(sessionId: string, code: number | null) { const session = getSession(sessionId); if (!session || session.stopped || code === 0 || code === null) return; + if ( + session.config.kind === "go" && + !useProject.getState().settings?.languages?.go?.goroot?.trim() && + /No version (?:is )?set for (?:shim: )?go/i.test(runtimes.get(sessionId)?.console.text() ?? "") + ) { + notifications().notify({ + group: "run", + level: "error", + key: `run-exit-${sessionId}`, + title: tx("Choose a Go SDK"), + body: tx("The Go version manager has no SDK selected. Choose an installed SDK in Go settings and run again."), + actions: [{ label: tx("Go Settings…"), run: () => void executeAction("file.settings", "languages.go") }], + }); + return; + } let visible = true; try { visible = toolWindowService().isVisible("run"); diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index a0cae520e..2fcc3b9aa 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -19,6 +19,9 @@ "Active frames keep running the old code until they return. New calls use the reloaded code.": "Active frames keep running the old code until they return. New calls use the reloaded code.", "The bottom frame cannot be reset. Re-enter the method or restart the debug session to run the new code.": "The bottom frame cannot be reset. Re-enter the method or restart the debug session to run the new code.", "Reset Frame ({0})": "Reset Frame ({0})", + "Choose a Go SDK": "Choose a Go SDK", + "The Go version manager has no SDK selected. Choose an installed SDK in Go settings and run again.": "The Go version manager has no SDK selected. Choose an installed SDK in Go settings and run again.", + "Go Settings…": "Go Settings…", "Active Editor": "Active Editor", "Soft-Wrap": "Soft-Wrap", "The language server Java executable ({path}) cannot run on this machine. Choose a working JDK in {setting}, or set it to Auto.": "The language server Java executable ({path}) cannot run on this machine. Choose a working JDK in {setting}, or set it to Auto.", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 54bfc7f81..7fe741fb0 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -11,6 +11,9 @@ "Active frames keep running the old code until they return. New calls use the reloaded code.": "活动栈帧将继续执行旧代码,直到方法返回。新的调用将使用重新加载的代码。", "The bottom frame cannot be reset. Re-enter the method or restart the debug session to run the new code.": "无法重置栈底帧。请重新进入该方法或重启调试会话,以执行新代码。", "Reset Frame ({0})": "重置栈帧({0})", + "Choose a Go SDK": "选择 Go SDK", + "The Go version manager has no SDK selected. Choose an installed SDK in Go settings and run again.": "Go 版本管理器未选择 SDK。请在 Go 设置中选择已安装的 SDK,然后重新运行。", + "Go Settings…": "Go 设置…", "Active Editor": "活动编辑器", "The language server Java executable ({path}) cannot run on this machine. Choose a working JDK in {setting}, or set it to Auto.": "语言服务器的 Java 可执行文件({path})无法在此机器上运行。请在 {setting} 中选择可正常运行的 JDK,或设为自动。", "Input queue is full (4 MiB or 1024 writes); input was not sent.": "输入队列已满(4 MiB 或 1024 次写入);本次输入未发送。",