Compare commits

...
Author SHA1 Message Date
l0ng-ai 8a0359ac3c fix(tools): install with the selected project SDK
Apply configured and project-pinned runtimes to Go, npm, gem and pip tool installation, prerequisite checks and probes. Pass the selected Python interpreter to uv virtualenv creation. Offer Go settings when Run fails through an unconfigured version-manager shim.
2026-11-09 18:07:23 +08:00
9 changed files with 279 additions and 10 deletions

No files matched your search

+2
View File
@@ -24,8 +24,10 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
.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))
+28
View File
@@ -130,6 +130,34 @@ pub(crate) fn goroot(root: &Path, s: &Settings) -> Option<PathBuf> {
.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<String, PathBuf>,
pub env: std::collections::BTreeMap<String, String>,
}
/// 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<Result<crate::java::Jdk, PathBuf>> {
let p = setting_path(root, value)?;
+23 -1
View File
@@ -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"));
}
+70 -1
View File
@@ -421,6 +421,7 @@ pub fn managed_bundle(app_data: &Path, name: &str) -> Option<PathBuf> {
}
/// 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<String, PathBuf>,
/// Executables found outside the search path (app bundles), by command.
found: BTreeMap<String, PathBuf>,
runtime_commands: BTreeMap<String, PathBuf>,
runtime_env: BTreeMap<String, String>,
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<String, PathBuf>, env: BTreeMap<String, String>) -> 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<PathBuf> =
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<String, PathBuf>) -> Self {
self.custom = custom;
@@ -493,6 +526,9 @@ impl Tools {
}
fn which(&self, cmd: &str) -> Option<PathBuf> {
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<std::ffi::OsStr>) -> 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<PathBuf> {
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<ToolInfo> {
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<ToolInfo> {
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<ToolInfo> {
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<ToolInfo> {
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<ToolInfo> {
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<ToolInfo> {
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)) {
+113 -8
View File
@@ -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 <home> --bindir <bin> 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}");
}
+22
View File
@@ -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<RunSession["runId"]>;
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…");
});
});
+15
View File
@@ -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");
+3
View File
@@ -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.",
+3
View File
@@ -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 次写入);本次输入未发送。",