fix(tools): install with the selected project SDK (#2334)

Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 18:13:57 +08:00
1 джерело 24d3173a55
коміт e2f22de824
9 files changed
+279 -10

No files matched your search

+2
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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 次写入);本次输入未发送。",