use std::fmt::Debug;
use std::path::PathBuf;
use std::process::Stdio;
use clap::Args;
use itertools::Itertools;
use miette::IntoDiagnostic;
use usage::Spec;
use crate::env;
#[derive(Debug, Args)]
#[clap(disable_help_flag = true, verbatim_doc_comment)]
pub struct Shell {
script: PathBuf,
#[clap(allow_hyphen_values = true)]
args: Vec<String>,
#[clap(short)]
h: bool,
#[clap(long)]
help: bool,
}
impl Shell {
pub fn run(&mut self, shell: &str) -> miette::Result<()> {
let spec = Spec::parse_file(&self.script)?;
let mut args = self.args.clone();
args.insert(0, spec.bin.clone());
if self.h {
return self.help(&spec, &args, false);
}
if self.help {
return self.help(&spec, &args, true);
}
let parsed = usage::parse::parse(&spec, &args)?;
debug!("{parsed:?}");
let overridden = env::shell_program_override(shell, |key| env::var(key).ok());
let program = overridden.clone().unwrap_or_else(|| shell.to_string());
debug!("running {program}");
let mut cmd = std::process::Command::new(&program);
cmd.stdin(Stdio::inherit());
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
let script_path = self
.script
.to_str()
.ok_or_else(|| miette::miette!("Invalid file path: {}", self.script.display()))?;
let args = std::iter::once(script_path.to_string())
.chain(self.args.clone())
.collect_vec();
cmd.args(&args);
env::apply_parsed_env(&mut cmd, &parsed.as_env());
let mut child = cmd.spawn().map_err(|err| match &overridden {
Some(_) => miette::miette!(
"failed to run `{program}` (from ${}): {err}",
env::shell_var_name(shell)
),
None => miette::miette!("failed to run `{program}`: {err}"),
})?;
let result = child.wait().into_diagnostic()?;
if !result.success() {
let code = result.code().unwrap_or(1);
if cfg!(windows) && overridden.is_none() {
if let Some(hint) = wsl_path_hint(shell, code, script_path) {
eprintln!("{hint}");
}
}
std::process::exit(code);
}
Ok(())
}
pub fn help(&self, spec: &Spec, args: &[String], long: bool) -> miette::Result<()> {
let parsed = usage::parse::parse_partial(spec, args)?;
println!("{}", usage::docs::cli::render_help(spec, &parsed.cmd, long));
Ok(())
}
}
fn looks_like_windows_path(path: &str) -> bool {
let bytes = path.as_bytes();
let drive_letter = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
drive_letter || path.starts_with(r"\\")
}
fn wsl_path_hint(shell: &str, code: i32, script: &str) -> Option<String> {
if shell != "bash" || code != 127 || !looks_like_windows_path(script) {
return None;
}
Some(format!(
"usage: `bash` exited 127 (command not found) and the script was given as a Windows path.\n\
usage: On Windows the system directory is searched before $PATH, so `bash` resolves to\n\
usage: C:\\Windows\\System32\\bash.exe — the WSL launcher — which cannot open `{script}`.\n\
usage: If that is what happened, pass the script by relative path, or point usage at the\n\
usage: bash you meant:\n\
usage: set {}=C:\\Program Files\\Git\\bin\\bash.exe\n\
usage: If the script really did exit 127 on its own, ignore this.",
env::shell_var_name(shell)
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_paths_are_recognized() {
for path in [r"C:\x", "C:/x", "c:/x", r"\\srv\share\x"] {
assert!(looks_like_windows_path(path), "{path}");
}
}
#[test]
fn a_drive_relative_path_counts_too() {
for path in ["C:x.sh", "c:x.sh"] {
assert!(looks_like_windows_path(path), "{path}");
}
}
#[test]
fn paths_a_posix_shell_can_open_are_not_windows_paths() {
for path in ["/c/x", "./x.sh", "x.sh", "/usr/local/bin/x", "", "C"] {
assert!(!looks_like_windows_path(path), "{path}");
}
}
#[test]
fn the_hint_names_the_script_and_the_override() {
let hint = wsl_path_hint("bash", 127, r"C:\Users\me\script.sh").unwrap();
assert!(hint.contains(r"C:\Users\me\script.sh"), "{hint}");
assert!(hint.contains("USAGE_SHELL_BASH"), "{hint}");
assert!(hint.contains("ignore this"), "{hint}");
}
#[test]
fn the_hint_stays_quiet_when_it_would_be_guessing() {
assert!(wsl_path_hint("bash", 1, r"C:\x").is_none());
assert!(wsl_path_hint("zsh", 127, r"C:\x").is_none());
assert!(wsl_path_hint("pwsh", 127, r"C:\x").is_none());
assert!(wsl_path_hint("bash", 127, "./x.sh").is_none());
}
}