//! Helpers for running a configuration under another tool (coverage, profilers): //! resolving its directory and tools, and taking its command line apart. use crate::RunConfig; use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; /// The configuration's working directory (relative ones against `root`). pub fn run_dir(root: &Path, config: &RunConfig) -> PathBuf { match config.cwd.as_deref() { Some(c) if c.is_absolute() => c.to_path_buf(), Some(c) if !c.as_os_str().is_empty() => root.join(c), _ => root.to_path_buf(), } } /// File name of `command` without a Windows executable extension: `/usr/bin/python3` → `python3`. pub fn program_name(command: &str) -> String { let name = command.rsplit(['/', '\\']).next().unwrap_or(command); let lower = name.to_ascii_lowercase(); for ext in [".exe", ".cmd", ".bat"] { if lower.ends_with(ext) { return name[..name.len() - ext.len()].to_owned(); } } name.to_owned() } /// `python`, `python3`, `python3.12`, `py`. pub fn is_python(command: &str) -> bool { let n = program_name(command); n == "py" || n.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.')) } /// The search path the configuration runs with (its `PATH` override, else the IDE's: /// the login shell's and the version managers', so `mvn` / `gradle` installed by mise, /// asdf or Homebrew resolve when the app's own `PATH` is minimal). pub fn search_path(config: &RunConfig) -> OsString { config .env .iter() .find(|(k, _)| k.eq_ignore_ascii_case("PATH")) .map(|(_, v)| OsString::from(v)) .unwrap_or_else(ide_core::tool_path::search_path) } /// `node_modules/.bin/` in `dir` or an ancestor up to (and including) `root`. pub fn node_bin(name: &str, dir: &Path, root: &Path) -> Option { let names = crate::which::executable_names(name, cfg!(windows), std::env::var("PATHEXT").ok().as_deref()); for d in dir.ancestors() { let bin = d.join("node_modules").join(".bin"); if let Some(found) = names.iter().map(|n| bin.join(n)).find(|p| p.is_file()) { return Some(found); } if d == root { break; } } None } /// `node_modules/` in `dir` or an ancestor up to `root`. pub fn node_package(package: &str, dir: &Path, root: &Path) -> bool { node_package_json(package, dir, root).is_some() } /// The installed version of `node_modules/` (see [`node_package`]). pub fn node_package_version(package: &str, dir: &Path, root: &Path) -> Option { let text = std::fs::read_to_string(node_package_json(package, dir, root)?).ok()?; let json: serde_json::Value = serde_json::from_str(&text).ok()?; json.get("version")?.as_str().map(str::to_owned) } fn node_package_json(package: &str, dir: &Path, root: &Path) -> Option { for d in dir.ancestors() { let manifest = d.join("node_modules").join(package).join("package.json"); if manifest.is_file() { return Some(manifest); } if d == root { break; } } None } /// A tool from the project (`node_modules/.bin`) or the search path. pub fn find_tool(name: &str, config: &RunConfig, dir: &Path, root: &Path) -> Option { node_bin(name, dir, root).or_else(|| crate::which::find_executable(name, &search_path(config))) } /// Whether `argv` runs and exits successfully (version probes); output is discarded. pub fn probe(argv: &[String], dir: &Path, config: &RunConfig) -> bool { let Some((program, args)) = argv.split_first() else { return false }; let program = resolve(program, config); ide_core::process::prepare(&mut Command::new(program)) .args(args) .current_dir(dir) .envs(&config.env) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .is_ok_and(|s| s.success()) } /// `program` as spawnable: bare names through the configuration's search path. fn resolve(program: &str, config: &RunConfig) -> PathBuf { let p = Path::new(program); if p.components().count() == 1 && let Some(found) = crate::which::find_executable(program, &search_path(config)) { return found; } p.to_path_buf() } /// A Python command line split into the interpreter part and what it runs. #[derive(Clone, Debug, PartialEq, Eq)] pub struct PythonCommand { /// Everything up to and including the interpreter (`uv run python`, `.venv/bin/python -X utf8`). pub interpreter: Vec, /// `-m pytest …`, `script.py …`. pub target: Vec, /// `uv` / `poetry` / `pipenv` when the interpreter runs through a project manager. pub manager: Option, } /// Splits `python … script.py`, `python -m pytest …`, `.venv/bin/pytest …` and /// `uv run pytest …` style command lines. `None` when no Python is involved. pub fn python_command(config: &RunConfig) -> Option { let tokens: Vec = std::iter::once(config.command.clone()).chain(config.args.iter().cloned()).collect(); let manager = ["uv", "poetry", "pipenv", "pdm", "hatch", "rye"] .into_iter() .find(|m| program_name(&tokens[0]) == *m) .map(str::to_owned); for (k, t) in tokens.iter().enumerate() { if is_python(t) { let mut interpreter = tokens[..=k].to_vec(); let mut rest = &tokens[k + 1..]; // Interpreter options stay with the interpreter (`-u`, `-X utf8`); `-m` starts the target. while let Some(first) = rest.first() { if first == "-m" || !first.starts_with('-') { break; } let takes_value = matches!(first.as_str(), "-X" | "-W"); let n = if takes_value { 2 } else { 1 }.min(rest.len()); interpreter.extend(rest[..n].iter().cloned()); rest = &rest[n..]; } return Some(PythonCommand { interpreter, target: rest.to_vec(), manager }); } let name = program_name(t); if name == "pytest" || name == "py.test" { let target = std::iter::once("-m".to_owned()) .chain(std::iter::once("pytest".to_owned())) .chain(tokens[k + 1..].iter().cloned()) .collect(); let interpreter = if k > 0 { tokens[..k].iter().cloned().chain(std::iter::once("python".to_owned())).collect() } else if t.contains(['/', '\\']) { vec![sibling_python(Path::new(t))] } else { vec![if cfg!(windows) { "python" } else { "python3" }.to_owned()] }; return Some(PythonCommand { interpreter, target, manager }); } } None } /// The interpreter next to a virtualenv's `bin/pytest`. fn sibling_python(tool: &Path) -> String { let dir = tool.parent().unwrap_or(Path::new("")); let candidates: &[&str] = if cfg!(windows) { &["python.exe"] } else { &["python", "python3"] }; candidates .iter() .map(|n| dir.join(n)) .find(|p| p.exists()) .unwrap_or_else(|| dir.join(candidates[0])) .to_string_lossy() .into_owned() } /// The `package.json` script a `npm|pnpm|yarn|bun run