use anyhow::{Context, Result};
use clap::CommandFactory;
use clap_complete::{generate, shells};
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(clippy::enum_variant_names)]
pub enum Shell {
Bash,
Zsh,
Fish,
PowerShell,
Elvish,
}
impl Shell {
pub fn parse(s: &str) -> Option<Self> {
match s {
"bash" => Some(Shell::Bash),
"zsh" => Some(Shell::Zsh),
"fish" => Some(Shell::Fish),
"powershell" | "pwsh" => Some(Shell::PowerShell),
"elvish" => Some(Shell::Elvish),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Shell::Bash => "bash",
Shell::Zsh => "zsh",
Shell::Fish => "fish",
Shell::PowerShell => "powershell",
Shell::Elvish => "elvish",
}
}
pub fn relative_path(self) -> Option<&'static str> {
match self {
Shell::Zsh => Some(".zsh/completions/_drip"),
Shell::Bash => Some(".bash_completion.d/drip.bash"),
Shell::Fish => Some(".config/fish/completions/drip.fish"),
Shell::PowerShell | Shell::Elvish => None,
}
}
pub fn from_shell_var(value: &str) -> Option<Self> {
let last = Path::new(value).file_name().and_then(|n| n.to_str())?;
match Self::parse(last)? {
s @ (Shell::Bash | Shell::Zsh | Shell::Fish) => Some(s),
_ => None,
}
}
fn fingerprint(self) -> Option<&'static str> {
match self {
Shell::Zsh => Some("#compdef drip"),
Shell::Bash => Some("_drip()"),
Shell::Fish => Some("complete -c drip"),
Shell::PowerShell | Shell::Elvish => None,
}
}
pub fn activation_hint(self) -> &'static str {
match self {
Shell::Zsh => {
"\
Add to ~/.zshrc (once), then `source ~/.zshrc`:
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit && compinit"
}
Shell::Bash => {
"\
Add to ~/.bashrc (once), then `source ~/.bashrc`:
[ -f ~/.bash_completion.d/drip.bash ] && source ~/.bash_completion.d/drip.bash"
}
Shell::Fish => {
"\
Open a new fish session — `~/.config/fish/completions/` is autoloaded."
}
Shell::PowerShell => {
"\
Add to your PowerShell profile (`echo $PROFILE`), then start a new shell:
drip completions powershell | Out-String | Invoke-Expression"
}
Shell::Elvish => {
"\
Add to ~/.config/elvish/rc.elv, then start a new shell:
eval (drip completions elvish | slurp)"
}
}
}
}
pub fn run(shell: &str) -> Result<()> {
let s = Shell::parse(shell)
.with_context(|| format!("unsupported shell: {shell}. Supported: bash, zsh, fish"))?;
let mut out = std::io::stdout().lock();
write_script(s, &mut out)?;
Ok(())
}
fn write_script(shell: Shell, w: &mut impl Write) -> Result<()> {
let mut cmd = crate::Cli::command();
match shell {
Shell::Bash => generate(shells::Bash, &mut cmd, "drip", w),
Shell::Zsh => generate(shells::Zsh, &mut cmd, "drip", w),
Shell::Fish => generate(shells::Fish, &mut cmd, "drip", w),
Shell::PowerShell => generate(shells::PowerShell, &mut cmd, "drip", w),
Shell::Elvish => generate(shells::Elvish, &mut cmd, "drip", w),
}
Ok(())
}
fn generate_to_buf(shell: Shell) -> Vec<u8> {
let mut buf = Vec::new();
let _ = write_script(shell, &mut buf);
buf
}
pub fn detect_shell() -> Option<Shell> {
let v = std::env::var("SHELL").ok()?;
Shell::from_shell_var(&v)
}
fn home_path(shell: Shell) -> Option<PathBuf> {
Some(dirs::home_dir()?.join(shell.relative_path()?))
}
pub fn install_for_detected_shell() -> Result<Option<PathBuf>> {
let Some(shell) = detect_shell() else {
return Ok(None);
};
let Some(target) = home_path(shell) else {
return Ok(None);
};
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
}
let bytes = generate_to_buf(shell);
std::fs::write(&target, &bytes).with_context(|| format!("writing {target:?}"))?;
Ok(Some(target))
}
pub fn uninstall_for_detected_shell() -> Result<Option<PathBuf>> {
let Some(shell) = detect_shell() else {
return Ok(None);
};
let Some(target) = home_path(shell) else {
return Ok(None);
};
if !target.exists() {
return Ok(None);
}
let body = std::fs::read_to_string(&target).with_context(|| format!("reading {target:?}"))?;
let Some(fp) = shell.fingerprint() else {
return Ok(None);
};
if !body.contains(fp) {
return Ok(None);
}
std::fs::remove_file(&target).with_context(|| format!("removing {target:?}"))?;
Ok(Some(target))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_known_shells() {
assert_eq!(Shell::parse("bash"), Some(Shell::Bash));
assert_eq!(Shell::parse("zsh"), Some(Shell::Zsh));
assert_eq!(Shell::parse("fish"), Some(Shell::Fish));
assert_eq!(Shell::parse("tcsh"), None);
}
#[test]
fn from_shell_var_strips_path() {
assert_eq!(Shell::from_shell_var("/bin/zsh"), Some(Shell::Zsh));
assert_eq!(
Shell::from_shell_var("/usr/local/bin/fish"),
Some(Shell::Fish)
);
assert_eq!(Shell::from_shell_var("/bin/tcsh"), None);
}
#[test]
fn fingerprints_distinguish_auto_installable_shells() {
let zsh = String::from_utf8(generate_to_buf(Shell::Zsh)).unwrap();
assert!(zsh.starts_with(Shell::Zsh.fingerprint().unwrap()));
let bash = String::from_utf8(generate_to_buf(Shell::Bash)).unwrap();
assert!(bash.contains(Shell::Bash.fingerprint().unwrap()));
let fish = String::from_utf8(generate_to_buf(Shell::Fish)).unwrap();
assert!(fish.contains(Shell::Fish.fingerprint().unwrap()));
assert_eq!(Shell::PowerShell.fingerprint(), None);
assert_eq!(Shell::Elvish.fingerprint(), None);
}
#[test]
fn powershell_and_elvish_generate_nonempty_scripts() {
let ps = String::from_utf8(generate_to_buf(Shell::PowerShell)).unwrap();
assert!(ps.len() > 200, "powershell script too small");
assert!(ps.contains("drip"), "powershell script missing drip");
let elv = String::from_utf8(generate_to_buf(Shell::Elvish)).unwrap();
assert!(elv.len() > 100, "elvish script too small");
assert!(elv.contains("drip"));
}
#[test]
fn shell_var_only_matches_auto_installable_shells() {
assert_eq!(Shell::from_shell_var("/usr/bin/pwsh"), None);
assert_eq!(Shell::from_shell_var("/bin/zsh"), Some(Shell::Zsh));
}
}