use std::fmt::Debug;
use std::path::PathBuf;
use std::process::Stdio;
use itertools::Itertools;
use usage::miette::IntoDiagnostic;
use usage_rs::Args;
use usage::Spec;
use crate::env;
#[derive(Debug, Args)]
pub struct Shell {
#[usage(double_dash = "automatic")]
script: PathBuf,
#[usage(double_dash = "preserve")]
args: Vec<String>,
#[usage(short)]
h: bool,
#[usage(long)]
help: bool,
}
macro_rules! shell_command {
($ty:ident, $program:literal, $about:tt, $shell_specific:literal) => {
#[doc = "Run a script whose usage spec is written in its own comments"]
#[doc = ""]
#[doc = "Usually reached through the script's shebang: the kernel hands the"]
#[doc = "script and its arguments to `usage`, which parses them against the"]
#[doc = "`#USAGE` lines at the top of the file, then runs the script with this"]
#[doc = "shell. Each flag and argument reaches the script as an environment"]
#[doc = "variable named `usage_<name>`, so `--force` becomes `usage_force` and"]
#[doc = "`<file>` becomes `usage_file`. A value declared `var=#true` arrives as"]
#[doc = "one string, joined with `shell_words::join()` so that an element"]
#[doc = "containing a space stays quoted."]
#[doc = ""]
#[doc = "`-h` and `--help` print the script's help page rather than this one."]
#[doc = ""]
#[doc = $shell_specific]
#[derive(Debug, Args)]
#[usage(about = $about, unknown_flags = "value", verbatim_doc_comment)]
pub struct $ty {
#[usage(flatten)]
pub shell: Shell,
}
impl usage_rs::Run for $ty {
type Output = usage::miette::Result<()>;
fn run(mut self) -> Self::Output {
self.shell.run($program)
}
}
};
}
shell_command!(
Bash,
"bash",
"Execute a shell script using bash",
"This command's shebang is `#!/usr/bin/env -S usage bash`, and the\nprogram it runs is `bash` from PATH. `USAGECLI_SHELL_BASH` names a\ndifferent one, which is how it is pointed at Git Bash on a Windows\nmachine where `bash` is the WSL launcher."
);
shell_command!(
Fish,
"fish",
"Execute a shell script using fish",
"This command's shebang is `#!/usr/bin/env -S usage fish`, and the\nprogram it runs is `fish` from PATH. `USAGECLI_SHELL_FISH` names a\ndifferent one."
);
shell_command!(
PowerShell,
"pwsh",
"Execute a shell script using PowerShell",
"This command's shebang is `#!/usr/bin/env -S usage powershell`, and\nthe program it runs is `pwsh` from PATH. `USAGECLI_SHELL_PWSH` names a\ndifferent one, which is also how it is pointed at `powershell.exe` on a\nmachine that has only Windows PowerShell."
);
shell_command!(
Zsh,
"zsh",
"Execute a shell script using zsh",
"This command's shebang is `#!/usr/bin/env -S usage zsh`, and the\nprogram it runs is `zsh` from PATH. `USAGECLI_SHELL_ZSH` names a\ndifferent one."
);
thread_local! {
static SUPPLIED_WORDS: std::cell::RefCell<Vec<std::ffi::OsString>> = const {
std::cell::RefCell::new(Vec::new())
};
}
pub fn note_supplied_words(words: &[&std::ffi::OsStr]) {
SUPPLIED_WORDS.set(words.iter().map(|word| (*word).to_owned()).collect());
}
impl Shell {
pub fn run(&mut self, shell: &str) -> usage::miette::Result<()> {
let separator_before_script = {
let words = SUPPLIED_WORDS.take();
let mut rest = words
.iter()
.skip_while(|word| word.to_string_lossy().starts_with('-'));
rest.next();
let mut separated = false;
for word in rest {
if separated || !word.to_string_lossy().starts_with('-') {
break;
}
separated |= word == "--";
}
separated
};
let _copy = match ScriptCopy::for_stream(&self.script, shell)? {
Some(copy) => {
self.script = copy.path.clone();
Some(copy)
}
None => None,
};
let spec = Spec::parse_file(&self.script)?;
let mut args = self.args.clone();
if separator_before_script {
args.insert(0, "--".to_string());
}
args.insert(0, spec.bin.clone());
let asked = self
.args
.iter()
.filter(|_| !separator_before_script)
.take_while(|word| *word != "--")
.find_map(|word| match word.as_str() {
"-h" => Some(false),
"--help" => Some(true),
_ => None,
});
if self.h || asked == Some(false) {
return self.help(&spec, &args, false);
}
if self.help || asked == Some(true) {
return self.help(&spec, &args, true);
}
let parsed = usage::parse::parse(&spec, &args)?;
debug!("{parsed:?}");
let overridden = env::shell_program_override_entry(shell, |key| env::var(key).ok());
let program = overridden
.as_ref()
.map_or_else(|| shell.to_string(), |(_, value)| value.clone());
debug!("running {program}");
let mut cmd = std::process::Command::new(&program);
cmd.stdin(Stdio::inherit());
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
let run_path = _copy.as_ref().map_or(&self.script, |copy| ©.run_path);
let script_path = run_path
.to_str()
.ok_or_else(|| usage::miette::miette!("Invalid file path: {}", run_path.display()))?
.to_string();
let mut script_args = self.args.clone();
if !separator_before_script
&& let Some(separator) = script_args.iter().position(|word| word == "--")
{
script_args.remove(separator);
}
let args = std::iter::once(script_path.clone())
.chain(script_args)
.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((key, _)) => {
usage::miette::miette!("failed to run `{program}` (from ${key}): {err}")
}
None => usage::miette::miette!("failed to run `{program}`: {err}"),
})?;
let result = child.wait().into_diagnostic()?;
if !result.success() {
let code = result.code().unwrap_or(1);
drop(_copy);
if cfg!(windows)
&& overridden.is_none()
&& 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) -> usage::miette::Result<()> {
let parsed = usage::parse::parse_partial(spec, args)?;
print!(
"{}",
usage::docs::cli::render_runtime_help(
spec,
&parsed.cmd,
long,
usage::docs::cli::Style::auto(),
None,
)
);
Ok(())
}
}
struct ScriptCopy {
dir: PathBuf,
path: PathBuf,
run_path: PathBuf,
}
impl ScriptCopy {
fn for_stream(script: &std::path::Path, shell: &str) -> usage::miette::Result<Option<Self>> {
match std::fs::metadata(script) {
Ok(meta) if !meta.is_file() && !meta.is_dir() => {}
_ => return Ok(None),
}
let content = std::fs::read(script).into_diagnostic()?;
let dir = private_temp_dir()?;
let name = script
.file_name()
.map_or_else(|| "script".into(), |n| n.to_os_string());
let path = dir.join(&name);
let run_path = if shell == "pwsh" {
let mut ps1 = name;
ps1.push(".ps1");
dir.join(ps1)
} else {
path.clone()
};
let copy = Self {
dir,
path,
run_path,
};
std::fs::write(©.path, &content).into_diagnostic()?;
if copy.run_path != copy.path {
std::fs::write(©.run_path, &content).into_diagnostic()?;
}
Ok(Some(copy))
}
}
impl Drop for ScriptCopy {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn private_temp_dir() -> usage::miette::Result<PathBuf> {
#[cfg_attr(not(unix), allow(unused_mut))]
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
let base = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.subsec_nanos());
let mut last_err = None;
for attempt in 0..16u32 {
let dir = base.join(format!(
"usage-script-{}-{nanos:x}-{attempt}",
std::process::id()
));
match builder.create(&dir) {
Ok(()) => return Ok(dir),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => last_err = Some(err),
Err(err) => return Err(err).into_diagnostic(),
}
}
Err(last_err.expect("the loop ran")).into_diagnostic()
}
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("USAGECLI_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());
}
}