diff --git a/src/bin/juliainstaller.rs b/src/bin/juliainstaller.rs index 9d6fbb38..4b9426e0 100644 --- a/src/bin/juliainstaller.rs +++ b/src/bin/juliainstaller.rs @@ -566,17 +566,24 @@ pub fn main() -> Result<()> { println!("Julia was successfully installed on your system."); if install_choices.modifypath { - println!(); - println!("Depending on which shell you are using, run one of the following"); - println!( - "commands to reload the {} environment variable:", - style("PATH").bold() - ); - println!(); - for p in &install_choices.modifypath_files { - println!(" . {}", p.to_string_lossy()); + use juliaup::shell::active_shells; + let source_commands: Vec = active_shells() + .into_iter() + .filter_map(|s| s.source_hint()) + .collect(); + if !source_commands.is_empty() { + println!(); + println!("Depending on which shell you are using, run one of the following"); + println!( + "commands to reload the {} environment variable:", + style("PATH").bold() + ); + println!(); + for command in &source_commands { + println!(" {}", command); + } + println!(); } - println!(); } Ok(()) diff --git a/src/lib.rs b/src/lib.rs index 88a00d10..9a65d788 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,6 +32,7 @@ pub mod config_file; pub mod global_paths; pub mod jsonstructs_versionsdb; pub mod operations; +pub mod shell; pub mod utils; pub mod version_selection; pub mod versions_file; diff --git a/src/operations.rs b/src/operations.rs index 9423a716..62f7996f 100644 --- a/src/operations.rs +++ b/src/operations.rs @@ -12,6 +12,7 @@ use crate::get_bundled_julia_version; use crate::get_juliaup_target; use crate::global_paths::GlobalPaths; use crate::jsonstructs_versionsdb::JuliaupVersionDB; +use crate::shell::all_shells; use crate::utils::check_server_supports_nightlies; use crate::utils::get_bin_dir; use crate::utils::get_julianightlies_base_url; @@ -26,7 +27,6 @@ use console::style; #[cfg(not(target_os = "freebsd"))] use flate2::read::GzDecoder; use indicatif::{ProgressBar, ProgressStyle}; -use indoc::formatdoc; use regex::Regex; use semver::Version; #[cfg(not(windows))] @@ -1656,105 +1656,6 @@ const S_MARKER: &[u8] = b"# >>> juliaup initialize >>>"; const E_MARKER: &[u8] = b"# <<< juliaup initialize <<<"; const HEADER: &[u8] = b"\n\n# !! Contents within this block are managed by juliaup !!\n\n"; -fn get_shell_script_juliaup_content( - bin_path: &Path, - juliauphome: &Path, - path: &Path, -) -> Result> { - let mut result: Vec = Vec::new(); - - let bin_path_str = match bin_path.to_str() { - Some(s) => s, - None => bail!("Could not create UTF-8 string from passed-in binary application path. Currently only valid UTF-8 paths are supported"), - }; - - let juliauphome_str = match juliauphome.to_str() { - Some(s) => s, - None => bail!("Could not create UTF-8 string from juliaup home path."), - }; - - result.extend_from_slice(S_MARKER); - result.extend_from_slice(HEADER); - let file_name = path - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| anyhow!("Could not determine file name for path: {}", path.display()))?; - if file_name == ".zshrc" { - append_zsh_content(&mut result, bin_path_str); - } else if path.file_name().unwrap() == ".cshrc" || path.file_name().unwrap() == ".tcshrc" { - append_csh_content(&mut result, bin_path_str); - } else { - append_sh_content(&mut result, bin_path_str); - } - if file_name == ".zshrc" || file_name.starts_with(".bash") { - append_completions_content(&mut result, file_name, juliauphome_str); - } - result.extend_from_slice(b"\n"); - result.extend_from_slice(E_MARKER); - - Ok(result) -} - -fn append_zsh_content(buf: &mut Vec, path_str: &str) { - // zsh specific syntax for path extension - let content = formatdoc!( - " - path=('{}' $path) - export PATH - ", - path_str - ); - - buf.extend_from_slice(content.as_bytes()); -} - -fn append_csh_content(buf: &mut Vec, path_str: &str) { - // csh specific syntax for path extension - let content = formatdoc!( - " - set path = ({} $path) - ", - path_str - ); - - buf.extend_from_slice(content.as_bytes()); -} - -fn append_sh_content(buf: &mut Vec, path_str: &str) { - // If the variable is already contained in $PATH, do nothing - // Otherwise prepend it to path - // ${PATH:+:${PATH}} => Only append :$PATH if $PATH is set - let content = formatdoc!( - " - case \":$PATH:\" in - *:{0}:*) - ;; - - *) - export PATH={0}${{PATH:+:${{PATH}}}} - ;; - esac - ", - path_str - ); - buf.extend_from_slice(content.as_bytes()); -} - -fn append_completions_content(buf: &mut Vec, file_name: &str, juliauphome: &str) { - let (shell, ext) = if file_name == ".zshrc" { - ("zsh", "zsh") - } else { - ("bash", "sh") - }; - let content = formatdoc!( - r#" - # Tab completion for juliaup and julia channel selection - [ -f "{juliauphome}/completions/{shell}.{ext}" ] && source "{juliauphome}/completions/{shell}.{ext}" - "#, - ); - buf.extend_from_slice(content.as_bytes()); -} - fn match_markers(buffer: &[u8]) -> Result> { let start_marker = buffer.find(S_MARKER); let end_marker = buffer.find(E_MARKER); @@ -1781,7 +1682,12 @@ fn match_markers(buffer: &[u8]) -> Result> { Ok(Some((start_marker, end_marker + E_MARKER.len()))) } -fn add_path_to_specific_file(bin_path: &Path, juliauphome: &Path, path: &Path) -> Result<()> { +fn write_marker_block(path: &Path, content: &[u8]) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + let mut file = std::fs::OpenOptions::new() .read(true) .write(true) @@ -1802,13 +1708,7 @@ fn add_path_to_specific_file(bin_path: &Path, juliauphome: &Path, path: &Path) - ) })?; - let new_content = - get_shell_script_juliaup_content(bin_path, juliauphome, path).with_context(|| { - format!( - "Error occured while generating juliaup shell startup script section for {}", - path.display() - ) - })?; + let new_content: Vec = [S_MARKER, HEADER, content, b"\n", E_MARKER].concat(); match existing_code_pos { Some(pos) => { @@ -1836,7 +1736,7 @@ fn add_path_to_specific_file(bin_path: &Path, juliauphome: &Path, path: &Path) - Ok(()) } -fn remove_path_from_specific_file(path: &Path) -> Result<()> { +fn remove_marker_block(path: &Path) -> Result<()> { let mut file = std::fs::OpenOptions::new() .read(true) .write(true) @@ -1870,29 +1770,17 @@ fn remove_path_from_specific_file(path: &Path) -> Result<()> { } pub fn find_shell_scripts_to_be_modified(add_case: bool) -> Result> { - let home_dir = dirs::home_dir().unwrap(); - - let paths_to_test: Vec = vec![ - home_dir.join(".bashrc"), - home_dir.join(".profile"), - home_dir.join(".bash_profile"), - home_dir.join(".bash_login"), - home_dir.join(".zshrc"), - home_dir.join(".cshrc"), - home_dir.join(".tcshrc"), - ]; - - let result = paths_to_test - .iter() + let result = all_shells() + .into_iter() + .flat_map(|s| s.all_rcfiles()) .filter( |p| { p.exists() || (add_case - && p.file_name().unwrap() == ".zshrc" + && p.file_name().unwrap() == ".zshenv" && std::env::consts::OS == "macos") }, // On MacOS, always edit .zshrc as that is the default shell, but only when we add things ) - .cloned() .collect(); Ok(result) } @@ -1901,20 +1789,24 @@ pub fn add_binfolder_to_path_in_shell_scripts(bin_path: &Path, juliauphome: &Pat write_completion_files::(juliauphome, "juliaup") .with_context(|| "Failed to write completion files.")?; - let paths = find_shell_scripts_to_be_modified(true)?; + for shell in all_shells() { + let content = shell.env_script(bin_path, juliauphome)?; + for rc in shell.rcfiles_to_write() { + write_marker_block(&rc, &content)?; + } + } - paths.into_iter().for_each(|p| { - add_path_to_specific_file(bin_path, juliauphome, &p).unwrap(); - }); Ok(()) } pub fn remove_binfolder_from_path_in_shell_scripts() -> Result<()> { - let paths = find_shell_scripts_to_be_modified(false)?; - - paths.into_iter().for_each(|p| { - remove_path_from_specific_file(&p).unwrap(); - }); + for shell in all_shells() { + for rc in shell.all_rcfiles() { + if rc.exists() { + remove_marker_block(&rc)?; + } + } + } Ok(()) } @@ -1922,12 +1814,13 @@ pub fn remove_binfolder_from_path_in_shell_scripts() -> Result<()> { /// This is called during self-update to propagate changes (e.g. new completions) /// to existing users without requiring them to re-run `juliaup config modifypath true`. pub fn refresh_existing_shell_init_blocks(bin_path: &Path, juliauphome: &Path) -> Result<()> { - let paths = find_shell_scripts_to_be_modified(false)?; - - for p in paths { - let content = std::fs::read(&p).unwrap_or_default(); - if match_markers(&content).unwrap_or(None).is_some() { - add_path_to_specific_file(bin_path, juliauphome, &p).ok(); + for shell in all_shells() { + for rc in shell.all_rcfiles() { + let content = std::fs::read(&rc).unwrap_or_default(); + if match_markers(&content).unwrap_or(None).is_some() { + write_marker_block(&rc, &shell.env_script(bin_path, juliauphome)?).ok(); + break; + } } } Ok(()) diff --git a/src/shell.rs b/src/shell.rs new file mode 100644 index 00000000..5215c61c --- /dev/null +++ b/src/shell.rs @@ -0,0 +1,318 @@ +//! Shell-specific PATH and completions setup. +//! +//! When juliaup installs, it places `julia` and `juliaup` binaries in +//! `~/.juliaup/bin`. For those commands to be available in the user's shell, +//! that directory needs to be on PATH — and ideally that should survive new +//! terminal sessions without the user having to do anything manually. +//! +//! The challenge is that each shell has its own conventions for which rc files +//! are sourced, when, and in what order. Login shells, interactive shells, and +//! GUI terminals all behave differently across sh, bash, zsh, tcsh, and fish. +//! Rather than trying to pick the "one right file", juliaup writes a small +//! initialisation block into whichever rc files are appropriate for each shell, +//! wrapped in clearly delimited markers so it can be updated or removed cleanly. +//! +//! Each shell is represented as a struct implementing [`UnixShell`]. See the +//! trait documentation for the methods each shell must provide. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +pub trait UnixShell { + // Detects if a shell "exists". Users have multiple shells, so an "eager" + // heuristic should be used, assuming shells exist if any traces do. + fn does_exist(&self) -> bool; + + // Returns the display name of the shell, used in post-install messages. + fn name(&self) -> &'static str; + + // Gives all rcfiles of a given shell that Rustup is concerned with. + // Used primarily in checking rcfiles for cleanup. + fn all_rcfiles(&self) -> Vec; + + // The subset of `all_rcfiles` that should actually be written to. + // Default: rc files that already exist on disk. + fn rcfiles_to_write(&self) -> Vec { + self.all_rcfiles() + .into_iter() + .filter(|p| p.exists()) + .collect() + } + + // The raw script template for this shell (placeholders: `{bin_path}`, `{juliauphome}`). + fn template(&self) -> &'static str; + + // Writes the relevant env file. + fn env_script(&self, bin_path: &Path, juliauphome: &Path) -> Result> { + let bin_str = bin_path.to_str().context("Non-UTF-8 binary path")?; + let home_str = juliauphome.to_str().context("Non-UTF-8 juliauphome path")?; + Ok(self + .template() + .replace("{bin_path}", bin_str) + .replace("{juliauphome}", home_str) + .into_bytes()) + } + + /// The command the user should run right now to pick up the new PATH, + /// without opening a new terminal. Returns `None` for shells that don't + /// need an explicit reload (e.g. fish, which uses conf.d/). + fn source_hint(&self) -> Option; +} + +#[cfg(not(windows))] +pub fn all_shells() -> Vec> { + vec![ + Box::new(Posix), + Box::new(Bash), + Box::new(Zsh), + Box::new(Tcsh), + Box::new(Fish), + ] +} + +#[cfg(windows)] +pub fn all_shells() -> Vec> { + vec![] +} + +/// Shells that appear to be present on the current system. +#[cfg(not(windows))] +pub fn active_shells() -> Vec> { + all_shells() + .into_iter() + .filter(|s| s.does_exist()) + .collect() +} + +#[cfg(windows)] +pub fn active_shells() -> Vec> { + vec![] +} + +/// Covers POSIX-compatible shells: sh, ash, dash, pdksh — all source `.profile`. +pub struct Posix; + +impl UnixShell for Posix { + fn does_exist(&self) -> bool { + // .profile is the POSIX baseline; always write to it so any sh-compatible + // shell picks up the PATH update. + true + } + + fn name(&self) -> &'static str { + "sh/ash/dash/pdksh" + } + + fn all_rcfiles(&self) -> Vec { + let Some(home) = dirs::home_dir() else { + return vec![]; + }; + vec![home.join(".profile")] + } + + fn template(&self) -> &'static str { + include_str!("shell_scripts/env.sh") + } + + fn source_hint(&self) -> Option { + self.all_rcfiles() + .into_iter() + .find(|p| p.exists()) + .map(|p| format!(". {}", p.display())) + } +} + +pub struct Bash; + +impl UnixShell for Bash { + fn does_exist(&self) -> bool { + !self.rcfiles_to_write().is_empty() + } + + fn name(&self) -> &'static str { + "bash" + } + + fn all_rcfiles(&self) -> Vec { + let Some(home) = dirs::home_dir() else { + return vec![]; + }; + [".bashrc", ".bash_profile", ".bash_login"] + .iter() + .map(|f| home.join(f)) + .collect() + } + + fn template(&self) -> &'static str { + include_str!("shell_scripts/env.bash") + } + + fn source_hint(&self) -> Option { + self.rcfiles_to_write() + .into_iter() + .next() + .map(|p| format!(". {}", p.display())) + } +} + +pub struct Zsh; + +impl Zsh { + /// Returns the directory zsh reads its dotfiles from: `$ZDOTDIR` if set, + /// otherwise `$HOME`. + fn zdotdir() -> Option { + if let Ok(dir) = std::env::var("ZDOTDIR") { + if !dir.is_empty() { + return Some(PathBuf::from(dir)); + } + } + dirs::home_dir() + } +} + +impl UnixShell for Zsh { + fn does_exist(&self) -> bool { + // zsh is the default shell on macOS, or the user has a .zshenv already. + std::env::consts::OS == "macos" + || Zsh::zdotdir() + .map(|d| d.join(".zshenv").exists()) + .unwrap_or(false) + } + + fn name(&self) -> &'static str { + "zsh" + } + + fn all_rcfiles(&self) -> Vec { + // Return both ZDOTDIR and HOME candidates so cleanup scans both locations, + // regardless of which was active when juliaup was first installed. + [Zsh::zdotdir(), dirs::home_dir()] + .into_iter() + .flatten() + .map(|d| d.join(".zshenv")) + .collect::>() + .into_iter() + .collect() + } + + fn template(&self) -> &'static str { + include_str!("shell_scripts/env.zsh") + } + + fn rcfiles_to_write(&self) -> Vec { + // Always write on macOS (default shell); elsewhere only if .zshenv exists. + self.all_rcfiles() + .into_iter() + .filter(|p| p.exists() || std::env::consts::OS == "macos") + .collect() + } + + fn source_hint(&self) -> Option { + self.all_rcfiles() + .into_iter() + .next() + .map(|p| format!(". {}", p.display())) + } +} + +pub struct Tcsh; + +impl UnixShell for Tcsh { + fn does_exist(&self) -> bool { + let Some(home) = dirs::home_dir() else { + return false; + }; + home.join(".cshrc").exists() || home.join(".tcshrc").exists() + } + + fn name(&self) -> &'static str { + "csh/tcsh" + } + + fn all_rcfiles(&self) -> Vec { + let Some(home) = dirs::home_dir() else { + return vec![]; + }; + vec![home.join(".cshrc"), home.join(".tcshrc")] + } + + fn template(&self) -> &'static str { + include_str!("shell_scripts/env.csh") + } + + fn source_hint(&self) -> Option { + self.all_rcfiles() + .into_iter() + .find(|p| p.exists()) + .map(|p| format!("source {}", p.display())) + } +} + +pub struct Fish; + +impl Fish { + /// Returns all candidate conf.d paths: XDG_CONFIG_HOME-based first, then + /// the ~/.config fallback. Both are included in all_rcfiles so cleanup + /// finds the file regardless of which was active at install time. + fn confd_paths() -> Vec { + let xdg = std::env::var_os("XDG_CONFIG_HOME") + .map(|x| PathBuf::from(x).join("fish/conf.d/juliaup.fish")); + let home = dirs::home_dir().map(|h| h.join(".config/fish/conf.d/juliaup.fish")); + xdg.into_iter() + .chain(home) + .collect::>() + .into_iter() + .collect() + } + + /// The single path that should be written to: XDG_CONFIG_HOME if set, + /// otherwise ~/.config. + fn confd_write_path() -> Option { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|h| h.join(".config")))?; + Some(base.join("fish/conf.d/juliaup.fish")) + } +} + +impl UnixShell for Fish { + fn does_exist(&self) -> bool { + // fish must either be the running shell or be callable. + std::env::var("SHELL") + .map(|s| s.contains("fish")) + .unwrap_or(false) + || which_fish() + } + + fn name(&self) -> &'static str { + "fish" + } + + fn all_rcfiles(&self) -> Vec { + // > "$XDG_CONFIG_HOME/fish/conf.d" (or "~/.config/fish/conf.d" if that variable is unset) for the user + // from + Fish::confd_paths() + } + + fn rcfiles_to_write(&self) -> Vec { + Fish::confd_write_path().into_iter().collect() + } + + fn template(&self) -> &'static str { + include_str!("shell_scripts/env.fish") + } + + /// Fish auto-loads conf.d/ on every new session — no reload needed. + fn source_hint(&self) -> Option { + None + } +} + +fn which_fish() -> bool { + std::process::Command::new("fish") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} diff --git a/src/shell_scripts/env.bash b/src/shell_scripts/env.bash new file mode 100644 index 00000000..5012837e --- /dev/null +++ b/src/shell_scripts/env.bash @@ -0,0 +1,10 @@ +case ":$PATH:" in + *:{bin_path}:*) + ;; + + *) + export PATH={bin_path}${PATH:+:${PATH}} + ;; +esac +# Tab completion for juliaup and julia channel selection +[ -f "{juliauphome}/completions/bash.sh" ] && source "{juliauphome}/completions/bash.sh" diff --git a/src/shell_scripts/env.csh b/src/shell_scripts/env.csh new file mode 100644 index 00000000..a109c970 --- /dev/null +++ b/src/shell_scripts/env.csh @@ -0,0 +1 @@ +set path = ({bin_path} $path) diff --git a/src/shell_scripts/env.fish b/src/shell_scripts/env.fish new file mode 100644 index 00000000..db1f7409 --- /dev/null +++ b/src/shell_scripts/env.fish @@ -0,0 +1,7 @@ +# juliaup PATH and completions +if not contains {bin_path} $PATH + set -x PATH {bin_path} $PATH +end +if test -f "{juliauphome}/completions/fish.fish" + source "{juliauphome}/completions/fish.fish" +end diff --git a/src/shell_scripts/env.sh b/src/shell_scripts/env.sh new file mode 100644 index 00000000..6a4eaab7 --- /dev/null +++ b/src/shell_scripts/env.sh @@ -0,0 +1,8 @@ +case ":$PATH:" in + *:{bin_path}:*) + ;; + + *) + export PATH={bin_path}${PATH:+:${PATH}} + ;; +esac diff --git a/src/shell_scripts/env.zsh b/src/shell_scripts/env.zsh new file mode 100644 index 00000000..ab11f28c --- /dev/null +++ b/src/shell_scripts/env.zsh @@ -0,0 +1,4 @@ +path=('{bin_path}' $path) +export PATH +# Tab completion for juliaup and julia channel selection +[ -f "{juliauphome}/completions/zsh.zsh" ] && source "{juliauphome}/completions/zsh.zsh"