#![allow(unknown_lints)]
use std::fmt::Display;
use indoc::formatdoc;
use shell_escape::unix::escape;
use crate::config::Settings;
use crate::shell::bash::Bash;
use crate::shell::{self, ActivateOptions, Shell};
#[derive(Default)]
pub(super) struct Zsh {}
impl Zsh {}
impl Shell for Zsh {
fn activate(&self, opts: ActivateOptions) -> String {
let exe = opts.exe;
let flags = opts.flags;
let exe = exe.to_string_lossy();
let exe = escape(crate::windows_posix::executable_for_shell(&exe));
let mut out = String::new();
out.push_str(&shell::build_deactivation_script(self));
out.push_str(&self.format_activate_prelude(&opts.prelude));
out.push_str(&formatdoc! {r#"
export MISE_SHELL=zsh
if [ -z "${{__MISE_ORIG_PATH:-}}" ]; then
export __MISE_ORIG_PATH="$PATH"
fi
export __MISE_ZSH_PRECMD_RUN=0
export __MISE_ZSH_CHPWD_RAN=0
mise() {{
local command
command="${{1:-}}"
if [ "$#" = 0 ]; then
command {exe}
return
fi
shift
case "$command" in
deactivate|shell|sh)
# if argv doesn't contains -h,--help
if [[ ! " $@ " =~ " --help " ]] && [[ ! " $@ " =~ " -h " ]]; then
eval "$(command {exe} "$command" "$@")"
return $?
fi
;;
esac
command {exe} "$command" "$@"
}}
"#});
if !opts.no_hook_env {
out.push_str(&formatdoc! {r#"
autoload -Uz add-zsh-hook
_mise_hook() {{
eval "$({exe} hook-env{flags} -s zsh "$@")";
}}
_mise_hook_env_state() {{
# enumerate MISE_* vars with the typeset builtin rather than
# ${{parameters}}: referencing that special array autoloads the
# zsh/parameter module via dlopen, which can deadlock under
# Rosetta in login shells (https://github.com/jdx/mise/discussions/11187)
local -a keys=(${{(o)${{(f)"$(typeset +m 'MISE_*' 2>/dev/null)"}}##* }})
if (( ${{#keys}} > 0 )); then
typeset -p "${{keys[@]}}" 2>/dev/null
fi
}}
_mise_hook_precmd() {{
if [[ "${{__MISE_ZSH_CHPWD_RAN:-0}}" == "1" ]]; then
export __MISE_ZSH_CHPWD_RAN=0
unset __MISE_ZSH_ACTIVATE_PATH
unset __MISE_ZSH_ACTIVATE_ENV
return
fi
if [[ "${{__MISE_ZSH_PRECMD_RUN:-0}}" == "0" &&
"$PATH" == "${{__MISE_ZSH_ACTIVATE_PATH:-}}" &&
"$(_mise_hook_env_state)" == "${{__MISE_ZSH_ACTIVATE_ENV:-}}" ]]; then
export __MISE_ZSH_PRECMD_RUN=1
unset __MISE_ZSH_ACTIVATE_PATH
unset __MISE_ZSH_ACTIVATE_ENV
return
fi
unset __MISE_ZSH_ACTIVATE_PATH
unset __MISE_ZSH_ACTIVATE_ENV
_mise_hook --reason precmd
}}
_mise_hook_chpwd() {{
export __MISE_ZSH_CHPWD_RAN=1
_mise_hook --reason chpwd
}}
add-zsh-hook precmd _mise_hook_precmd
add-zsh-hook chpwd _mise_hook_chpwd
_mise_hook
export __MISE_ZSH_ACTIVATE_PATH="$PATH"
export __MISE_ZSH_ACTIVATE_ENV="$(_mise_hook_env_state)"
"#});
}
if Settings::get().not_found_auto_install {
out.push_str(&formatdoc! {r#"
if [ -z "${{_mise_cmd_not_found:-}}" ]; then
_mise_cmd_not_found=1
[ -n "$(declare -f command_not_found_handler)" ] && eval "${{$(declare -f command_not_found_handler)/command_not_found_handler/_command_not_found_handler}}"
function command_not_found_handler() {{
if [[ "$1" != "mise" && "$1" != "mise-"* ]] && {exe} hook-not-found -s zsh -- "$1"; then
# Refresh inline rather than through `_mise_hook`: `--no-hook-env` omits
# that definition while still emitting this handler, and without a refresh
# "$@" runs before the tool just installed is on PATH. Called with no
# arguments `_mise_hook` is this same eval, so one inline call -- fish's
# shape -- covers both modes and leaves nothing to keep in sync.
#
# MISE_SHELL is the gate `deactivate` turns off: it unsets the variable but
# leaves this handler registered, and a shell the user deactivated must not
# have mise's environment applied back to it.
#
# `--force` because an install just happened: with `hook_env.cache_ttl` set
# and an inherited `__MISE_SESSION`, the TTL fast path returns before the
# check that would notice it, and "$@" would fail exactly as before.
if [ -n "${{MISE_SHELL:-}}" ]; then
eval "$({exe} hook-env{flags} --force -s zsh)"
fi
"$@"
elif [ -n "$(declare -f _command_not_found_handler)" ]; then
_command_not_found_handler "$@"
else
echo "zsh: command not found: $1" >&2
return 127
fi
}}
fi
"#});
}
out
}
fn deactivate(&self) -> String {
formatdoc! {r#"
autoload -Uz add-zsh-hook
add-zsh-hook -d precmd _mise_hook_precmd 2>/dev/null
add-zsh-hook -d chpwd _mise_hook_chpwd 2>/dev/null
(( $+functions[_mise_hook_precmd] )) && unset -f _mise_hook_precmd
(( $+functions[_mise_hook_chpwd] )) && unset -f _mise_hook_chpwd
(( $+functions[_mise_hook] )) && unset -f _mise_hook
(( $+functions[_mise_hook_env_state] )) && unset -f _mise_hook_env_state
(( $+functions[mise] )) && unset -f mise
unset MISE_SHELL
unset __MISE_DIFF
unset __MISE_SESSION
unset __MISE_ZSH_PRECMD_RUN
unset __MISE_ZSH_CHPWD_RAN
unset __MISE_ZSH_ACTIVATE_PATH
unset __MISE_ZSH_ACTIVATE_ENV
"#}
}
fn set_env(&self, k: &str, v: &str) -> String {
Bash::default().set_env(k, v)
}
fn prepend_env(&self, k: &str, v: &str) -> String {
Bash::default().prepend_env(k, v)
}
fn unset_env(&self, k: &str) -> String {
Bash::default().unset_env(k)
}
fn set_alias(&self, name: &str, cmd: &str) -> String {
Bash::default().set_alias(name, cmd)
}
fn unset_alias(&self, name: &str) -> String {
Bash::default().unset_alias(name)
}
}
impl Display for Zsh {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "zsh")
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use std::path::Path;
use test_log::test;
use crate::test::replace_path;
use super::*;
#[test]
fn test_activate() {
unsafe {
std::env::remove_var("__MISE_ORIG_PATH");
std::env::remove_var("__MISE_DIFF");
}
let zsh = Zsh::default();
let exe = Path::new("/some/dir/mise");
let opts = ActivateOptions {
exe: exe.to_path_buf(),
flags: " --status".into(),
no_hook_env: false,
prelude: vec![],
};
assert_snapshot!(zsh.activate(opts));
}
fn command_not_found_handler(script: &str) -> &str {
let start = script
.find("function command_not_found_handler()")
.expect("activate should emit a command-not-found handler");
&script[start..]
}
#[test]
fn test_activate_no_hook_env_refreshes_after_auto_install() {
let opts = ActivateOptions {
exe: Path::new("/some/dir/mise").to_path_buf(),
flags: " --status".into(),
no_hook_env: true,
prelude: vec![],
};
let script = Zsh::default().activate(opts);
assert!(!script.contains("_mise_hook() {"));
assert!(script.contains("hook-not-found -s zsh"));
assert!(script.contains("hook-env --status --force -s zsh"));
}
#[test]
fn test_activate_command_not_found_refresh_is_gated_on_mise_shell() {
for no_hook_env in [false, true] {
let opts = ActivateOptions {
exe: Path::new("/some/dir/mise").to_path_buf(),
flags: " --status".into(),
no_hook_env,
prelude: vec![],
};
let script = Zsh::default().activate(opts);
let handler = command_not_found_handler(&script);
let gate = handler
.find(r#"if [ -n "${MISE_SHELL:-}" ]; then"#)
.expect("the refresh should be gated on MISE_SHELL");
let refresh = handler
.find("hook-env --status --force -s zsh")
.expect("the handler should refresh the environment after an install");
assert!(gate < refresh, "the gate has to precede what it guards");
}
}
#[test]
fn test_activate_command_not_found_avoids_zsh_parameter_module() {
let opts = ActivateOptions {
exe: Path::new("/some/dir/mise").to_path_buf(),
flags: " --status".into(),
no_hook_env: true,
prelude: vec![],
};
let script = Zsh::default().activate(opts);
assert!(!command_not_found_handler(&script).contains("$+functions"));
}
#[test]
fn test_set_env() {
assert_snapshot!(Zsh::default().set_env("FOO", "1"));
}
#[test]
fn test_prepend_env() {
let sh = Bash::default();
assert_snapshot!(replace_path(&sh.prepend_env("PATH", "/some/dir:/2/dir")));
}
#[cfg(windows)]
#[test]
fn windows_path_casing_is_normalized_for_zsh() {
assert!(
Zsh::default()
.set_env("Path", r"C:\bin")
.starts_with("export PATH=")
);
}
#[test]
fn test_unset_env() {
assert_snapshot!(Zsh::default().unset_env("FOO"));
}
#[test]
fn test_deactivate() {
let deactivate = Zsh::default().deactivate();
assert_snapshot!(replace_path(&deactivate));
}
}