mirror of
https://github.com/astral-sh/uv.git
synced 2025-10-02 23:04:37 +00:00
Move shell manipulation into its own crate (#5028)
## Summary This is going to get a little more complex as we support Windows, so carving it out.
This commit is contained in:
parent
ba217f1059
commit
a61464e802
11 changed files with 42 additions and 17 deletions
248
crates/uv-shell/src/lib.rs
Normal file
248
crates/uv-shell/src/lib.rs
Normal file
|
@ -0,0 +1,248 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use uv_fs::Simplified;
|
||||
|
||||
/// Shells for which virtualenv activation scripts are available.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[allow(clippy::doc_markdown)]
|
||||
pub enum Shell {
|
||||
/// Bourne Again SHell (bash)
|
||||
Bash,
|
||||
/// Friendly Interactive SHell (fish)
|
||||
Fish,
|
||||
/// PowerShell
|
||||
Powershell,
|
||||
/// Cmd (Command Prompt)
|
||||
Cmd,
|
||||
/// Z SHell (zsh)
|
||||
Zsh,
|
||||
/// Nushell
|
||||
Nushell,
|
||||
/// C SHell (csh)
|
||||
Csh,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
/// Determine the user's current shell from the environment.
|
||||
///
|
||||
/// This will read the `SHELL` environment variable and try to determine which shell is in use
|
||||
/// from that.
|
||||
///
|
||||
/// If `SHELL` is not set, then on windows, it will default to powershell, and on
|
||||
/// other `OSes` it will return `None`.
|
||||
///
|
||||
/// If `SHELL` is set, but contains a value that doesn't correspond to one of the supported
|
||||
/// shell types, then return `None`.
|
||||
pub fn from_env() -> Option<Shell> {
|
||||
if std::env::var_os("NU_VERSION").is_some() {
|
||||
Some(Shell::Nushell)
|
||||
} else if std::env::var_os("FISH_VERSION").is_some() {
|
||||
Some(Shell::Fish)
|
||||
} else if std::env::var_os("BASH_VERSION").is_some() {
|
||||
Some(Shell::Bash)
|
||||
} else if std::env::var_os("ZSH_VERSION").is_some() {
|
||||
Some(Shell::Zsh)
|
||||
} else if let Some(env_shell) = std::env::var_os("SHELL") {
|
||||
Shell::from_shell_path(env_shell)
|
||||
} else if cfg!(windows) {
|
||||
// Command Prompt relies on PROMPT for its appearance whereas PowerShell does not.
|
||||
// See: https://stackoverflow.com/a/66415037.
|
||||
if std::env::var_os("PROMPT").is_some() {
|
||||
Some(Shell::Cmd)
|
||||
} else {
|
||||
// Fallback to PowerShell if the PROMPT environment variable is not set.
|
||||
Some(Shell::Powershell)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a shell from a path to the executable for the shell.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use crate::shells::Shell;
|
||||
///
|
||||
/// assert_eq!(Shell::from_shell_path("/bin/bash"), Some(Shell::Bash));
|
||||
/// assert_eq!(Shell::from_shell_path("/usr/bin/zsh"), Some(Shell::Zsh));
|
||||
/// assert_eq!(Shell::from_shell_path("/opt/my_custom_shell"), None);
|
||||
/// ```
|
||||
pub fn from_shell_path(path: impl AsRef<Path>) -> Option<Shell> {
|
||||
parse_shell_from_path(path.as_ref())
|
||||
}
|
||||
|
||||
/// Return the configuration files that should be modified to append to a shell's `PATH`.
|
||||
///
|
||||
/// Some of the logic here is based on rustup's rc file detection.
|
||||
///
|
||||
/// See: <https://github.com/rust-lang/rustup/blob/fede22fea7b160868cece632bd213e6d72f8912f/src/cli/self_update/shell.rs#L197>
|
||||
pub fn configuration_files(self) -> Vec<PathBuf> {
|
||||
let Some(home_dir) = home::home_dir() else {
|
||||
return vec![];
|
||||
};
|
||||
match self {
|
||||
Shell::Bash => {
|
||||
// On Bash, we need to update both `.bashrc` and `.bash_profile`. The former is
|
||||
// sourced for non-login shells, and the latter is sourced for login shells.
|
||||
//
|
||||
// In lieu of `.bash_profile`, shells will also respect `.bash_login` and
|
||||
// `.profile`, if they exist. So we respect those too.
|
||||
vec![
|
||||
[".bash_profile", ".bash_login", ".profile"]
|
||||
.iter()
|
||||
.map(|rc| home_dir.join(rc))
|
||||
.find(|rc| rc.is_file())
|
||||
.unwrap_or_else(|| home_dir.join(".bash_profile")),
|
||||
home_dir.join(".bashrc"),
|
||||
]
|
||||
}
|
||||
Shell::Zsh => {
|
||||
// On Zsh, we only need to update `.zshenv`. This file is sourced for both login and
|
||||
// non-login shells. However, we match rustup's logic for determining _which_
|
||||
// `.zshenv` to use.
|
||||
//
|
||||
// See: https://github.com/rust-lang/rustup/blob/fede22fea7b160868cece632bd213e6d72f8912f/src/cli/self_update/shell.rs#L197
|
||||
let zsh_dot_dir = std::env::var("ZDOTDIR")
|
||||
.ok()
|
||||
.filter(|dir| !dir.is_empty())
|
||||
.map(PathBuf::from);
|
||||
|
||||
// Attempt to update an existing `.zshenv` file.
|
||||
if let Some(zsh_dot_dir) = zsh_dot_dir.as_ref() {
|
||||
// If `ZDOTDIR` is set, and `ZDOTDIR/.zshenv` exists, then we update that file.
|
||||
let zshenv = zsh_dot_dir.join(".zshenv");
|
||||
if zshenv.is_file() {
|
||||
return vec![zshenv];
|
||||
}
|
||||
} else {
|
||||
// If `ZDOTDIR` is _not_ set, and `~/.zshenv` exists, then we update that file.
|
||||
let zshenv = home_dir.join(".zshenv");
|
||||
if zshenv.is_file() {
|
||||
return vec![zshenv];
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(zsh_dot_dir) = zsh_dot_dir.as_ref() {
|
||||
// If `ZDOTDIR` is set, then we create `ZDOTDIR/.zshenv`.
|
||||
vec![zsh_dot_dir.join(".zshenv")]
|
||||
} else {
|
||||
// If `ZDOTDIR` is _not_ set, then we create `~/.zshenv`.
|
||||
vec![home_dir.join(".zshenv")]
|
||||
}
|
||||
}
|
||||
Shell::Fish => {
|
||||
// On Fish, we only need to update `config.fish`. This file is sourced for both
|
||||
// login and non-login shells. However, we must respect Fish's logic, which reads
|
||||
// from `$XDG_CONFIG_HOME/fish/config.fish` if set, and `~/.config/fish/config.fish`
|
||||
// otherwise.
|
||||
if let Some(xdg_home_dir) = std::env::var("XDG_CONFIG_HOME")
|
||||
.ok()
|
||||
.filter(|dir| !dir.is_empty())
|
||||
.map(PathBuf::from)
|
||||
{
|
||||
vec![xdg_home_dir.join("fish/config.fish")]
|
||||
} else {
|
||||
vec![home_dir.join(".config/fish/config.fish")]
|
||||
}
|
||||
}
|
||||
Shell::Csh => {
|
||||
// On Csh, we need to update both `.cshrc` and `.login`, like Bash.
|
||||
vec![home_dir.join(".cshrc"), home_dir.join(".login")]
|
||||
}
|
||||
// TODO(charlie): Add support for Nushell, PowerShell, and Cmd.
|
||||
Shell::Nushell => vec![],
|
||||
Shell::Powershell => vec![],
|
||||
Shell::Cmd => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the given path is on the `PATH` in this shell.
|
||||
pub fn contains_path(path: &Path) -> bool {
|
||||
std::env::var_os("PATH")
|
||||
.as_ref()
|
||||
.iter()
|
||||
.flat_map(std::env::split_paths)
|
||||
.any(|p| same_file::is_same_file(path, p).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Returns the command necessary to prepend a directory to the `PATH` in this shell.
|
||||
pub fn prepend_path(self, path: &Path) -> Option<String> {
|
||||
match self {
|
||||
Shell::Nushell => None,
|
||||
Shell::Bash | Shell::Zsh => Some(format!(
|
||||
"export PATH=\"{}:$PATH\"",
|
||||
backslash_escape(&path.simplified_display().to_string()),
|
||||
)),
|
||||
Shell::Fish => Some(format!(
|
||||
"fish_add_path \"{}\"",
|
||||
backslash_escape(&path.simplified_display().to_string()),
|
||||
)),
|
||||
Shell::Csh => Some(format!(
|
||||
"setenv PATH \"{}:$PATH\"",
|
||||
backslash_escape(&path.simplified_display().to_string()),
|
||||
)),
|
||||
Shell::Powershell => Some(format!(
|
||||
"$env:PATH = \"{};$env:PATH\"",
|
||||
backtick_escape(&path.simplified_display().to_string()),
|
||||
)),
|
||||
Shell::Cmd => Some(format!(
|
||||
"set PATH=\"{};%PATH%\"",
|
||||
backslash_escape(&path.simplified_display().to_string()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Shell {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Shell::Bash => write!(f, "Bash"),
|
||||
Shell::Fish => write!(f, "Fish"),
|
||||
Shell::Powershell => write!(f, "PowerShell"),
|
||||
Shell::Cmd => write!(f, "Command Prompt"),
|
||||
Shell::Zsh => write!(f, "Zsh"),
|
||||
Shell::Nushell => write!(f, "Nushell"),
|
||||
Shell::Csh => write!(f, "Csh"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the shell from the name of the shell executable.
|
||||
fn parse_shell_from_path(path: &Path) -> Option<Shell> {
|
||||
let name = path.file_stem()?.to_str()?;
|
||||
match name {
|
||||
"bash" => Some(Shell::Bash),
|
||||
"zsh" => Some(Shell::Zsh),
|
||||
"fish" => Some(Shell::Fish),
|
||||
"csh" => Some(Shell::Csh),
|
||||
"powershell" | "powershell_ise" => Some(Shell::Powershell),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape a string for use in a shell command by inserting backslashes.
|
||||
fn backslash_escape(s: &str) -> String {
|
||||
let mut escaped = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' | '"' => escaped.push('\\'),
|
||||
_ => {}
|
||||
}
|
||||
escaped.push(c);
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
/// Escape a string for use in a `PowerShell` command by inserting backticks.
|
||||
fn backtick_escape(s: &str) -> String {
|
||||
let mut escaped = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' | '"' | '$' => escaped.push('`'),
|
||||
_ => {}
|
||||
}
|
||||
escaped.push(c);
|
||||
}
|
||||
escaped
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue