#![allow(unknown_lints)]
use crate::config::Settings;
use std::borrow::Cow;
use std::fmt::Display;
use indoc::formatdoc;
use crate::shell::{self, ActivateOptions, Shell};
#[derive(Default)]
pub(super) struct Pwsh {}
impl Pwsh {}
impl Shell for Pwsh {
fn activate(&self, opts: ActivateOptions) -> String {
let exe = opts.exe;
let flags = opts.flags;
let exe = escape_sq(&exe.to_string_lossy()).into_owned();
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#"
$env:MISE_SHELL = 'pwsh'
if (-not (Test-Path -Path Env:/__MISE_ORIG_PATH)) {{
$env:__MISE_ORIG_PATH = $env:PATH
}}
function mise {{
$arguments = $args
$previous_out_encoding = $OutputEncoding
$previous_console_out_encoding = [Console]::OutputEncoding
$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
function _reset_output_encoding {{
$OutputEncoding = $previous_out_encoding
[Console]::OutputEncoding = $previous_console_out_encoding
}}
if ($arguments.count -eq 0) {{
if ($MyInvocation.ExpectingInput) {{
$input | & '{exe}'
}} else {{
& '{exe}'
}}
_reset_output_encoding
return
}} elseif ($arguments -contains '-h' -or $arguments -contains '--help') {{
if ($MyInvocation.ExpectingInput) {{
$input | & '{exe}' @arguments
}} else {{
& '{exe}' @arguments
}}
_reset_output_encoding
return
}}
$command = $arguments[0]
if ($arguments.Length -gt 1) {{
$remainingArgs = $arguments[1..($arguments.Length - 1)]
}} else {{
$remainingArgs = @()
}}
switch ($command) {{
{{ $_ -in 'deactivate', 'shell', 'sh' }} {{
if ($MyInvocation.ExpectingInput) {{
$input | & '{exe}' $command @remainingArgs | Out-String | Invoke-Expression -ErrorAction SilentlyContinue
}} else {{
& '{exe}' $command @remainingArgs | Out-String | Invoke-Expression -ErrorAction SilentlyContinue
}}
_reset_output_encoding
}}
default {{
if ($MyInvocation.ExpectingInput) {{
$input | & '{exe}' $command @remainingArgs
}} else {{
& '{exe}' $command @remainingArgs
}}
if ($(Test-Path -Path Function:\_mise_hook)){{
_mise_hook
}}
_reset_output_encoding
}}
}}
}}
"#});
if !opts.no_hook_env {
out.push_str(&formatdoc! {r#"
function Global:_mise_hook {{
if ($env:MISE_SHELL -eq "pwsh"){{
$status = $global:LASTEXITCODE
$output = & '{exe}' hook-env{flags} $args -s pwsh | Out-String
if ($output -and $output.Trim()) {{
$output | Invoke-Expression
}}
# mise hook-env will have set $LASTEXITCODE, restore previous value
$global:LASTEXITCODE = $status
}}
}}
# Declared up front because the prompt reads it before any directory change
# has set it, and Set-StrictMode makes reading an unset variable an error.
$Global:__mise_pwsh_chpwd_handled = $null
function __enable_mise_chpwd{{
if ($PSVersionTable.PSVersion.Major -lt 7) {{
if ($env:MISE_PWSH_CHPWD_WARNING -ne '0') {{
Write-Warning "mise: chpwd functionality requires PowerShell version 7 or higher. Your current version is $($PSVersionTable.PSVersion). You can add `$env:MISE_PWSH_CHPWD_WARNING=0` to your environment to disable this warning."
}}
return
}}
if (-not (Test-Path variable:global:__mise_pwsh_chpwd)){{
$Global:__mise_pwsh_chpwd= $true
$_mise_chpwd_hook = [EventHandler[System.Management.Automation.LocationChangedEventArgs]] {{
param([object] $source, [System.Management.Automation.LocationChangedEventArgs] $eventArgs)
end {{
_mise_hook
# The prompt fires immediately after this handler, and its own
# hook-env would find the directory unchanged and early-exit. On
# Windows that costs a whole process (~22ms, almost entirely
# CreateProcess) to learn nothing. Record the directory and MISE_*
# state already handled so the prompt can skip it. $PWD is the new
# location by the time this handler runs.
$Global:__mise_pwsh_chpwd_handled = [PSCustomObject]@{{
Path = $PWD.Path
MiseEnv = [string]::Join("`0", [string[]]@(
Get-ChildItem Env:MISE_* |
Sort-Object Name |
ForEach-Object {{ "$($_.Name)`0$($_.Value)" }}
))
}}
}}
}};
$__mise_pwsh_previous_chpwd_function=$ExecutionContext.SessionState.InvokeCommand.LocationChangedAction;
if ($__mise_pwsh_previous_chpwd_function) {{
$ExecutionContext.SessionState.InvokeCommand.LocationChangedAction = [Delegate]::Combine($__mise_pwsh_previous_chpwd_function, $_mise_chpwd_hook)
}}
else {{
$ExecutionContext.SessionState.InvokeCommand.LocationChangedAction = $_mise_chpwd_hook
}}
}}
}}
__enable_mise_chpwd
Remove-Item -ErrorAction SilentlyContinue -Path Function:/__enable_mise_chpwd
function __enable_mise_prompt {{
if (-not (Test-Path variable:global:__mise_pwsh_previous_prompt_function)){{
$Global:__mise_pwsh_previous_prompt_function=$function:prompt
function global:prompt {{
if (Test-Path -Path Function:\_mise_hook){{
# Skip only when the chpwd handler already ran hook-env for this
# exact directory and the MISE_* state has not changed since.
$handled = if (Test-Path variable:global:__mise_pwsh_chpwd_handled) {{
$Global:__mise_pwsh_chpwd_handled
}} else {{
$null
}}
if (Test-Path variable:global:__mise_pwsh_chpwd_handled) {{
$Global:__mise_pwsh_chpwd_handled = $null
}}
if (
$null -eq $handled -or
$handled.Path -ne $PWD.Path -or
$handled.MiseEnv -ne [string]::Join("`0", [string[]]@(
Get-ChildItem Env:MISE_* |
Sort-Object Name |
ForEach-Object {{ "$($_.Name)`0$($_.Value)" }}
))
) {{
_mise_hook
}}
}}
& $__mise_pwsh_previous_prompt_function
}}
}}
}}
__enable_mise_prompt
Remove-Item -ErrorAction SilentlyContinue -Path Function:/__enable_mise_prompt
_mise_hook
"#});
}
if Settings::get().not_found_auto_install {
out.push_str(&formatdoc! {r#"
if (-not (Test-Path variable:global:__mise_pwsh_command_not_found)){{
$Global:__mise_pwsh_command_not_found= $true
function __enable_mise_command_not_found {{
$_mise_pwsh_cmd_not_found_hook = [EventHandler[System.Management.Automation.CommandLookupEventArgs]] {{
param([object] $Name, [System.Management.Automation.CommandLookupEventArgs] $eventArgs)
end {{
# mise's own commands are not tools: `mise-foo` must not be
# looked up as something to install, and `deactivate` removes
# the wrapper function while leaving this handler registered,
# so `mise` itself reaches here too. bash, zsh and fish all
# skip these names before calling hook-not-found. `-like`
# rather than a prefix match: `mise2` is somebody else's tool.
if ($Name -eq 'mise' -or $Name -like 'mise-*') {{ return }}
# Only auto-install when the missing command is what the
# user actually typed. PSReadLine is absent in
# non-interactive sessions, and even when its module is
# loaded GetHistoryItems() throws until the line editor
# initializes, so treat "cannot tell" as "not typed".
$lastCommand = $null
try {{
$psReadLine = 'Microsoft.PowerShell.PSConsoleReadLine' -as [type]
if ($psReadLine) {{
$history = @($psReadLine::GetHistoryItems())
if ($history.Count -gt 0) {{
$lastCommand = $history[-1].CommandLine
}}
}}
}} catch {{ }}
# compare whole tokens: a substring match would fire for
# `mise` when the user typed `premise`, while matching only
# the first token would miss `... | some-missing-tool`
if ($lastCommand -and (($lastCommand -split '\s+') -contains $Name)) {{
# `hook-not-found` answers with an exit code and writes nothing to
# stdout, and `if (& ...)` in PowerShell tests the *output* rather
# than the code: it was false even when a tool had been installed,
# and would have been true for a failure that happened to print.
# bash, zsh and fish put the command straight into the condition
# and get its status; this is the same thing spelled for pwsh.
& '{exe}' hook-not-found -s pwsh -- $Name | Out-Null
if ($LASTEXITCODE -eq 0){{
# Refresh inline rather than through `_mise_hook`:
# `--no-hook-env` omits that definition while still emitting
# this block, an unresolved name inside a
# CommandNotFoundAction throws out of the handler, and
# skipping the refresh leaves the tool just installed off
# PATH for the handoff below. fish inlines `hook-env` here
# for the same reason.
#
# The MISE_SHELL check is the one `_mise_hook` carries.
# `deactivate` unsets the variable but leaves this handler
# registered, and a CommandNotFoundAction runs in-process:
# without the gate, a deactivated session would get mise's
# PATH and `__MISE_SESSION` re-applied for good.
#
# `--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 the handoff below would find nothing.
if ($env:MISE_SHELL -eq "pwsh"){{
$output = & '{exe}' hook-env{flags} --force -s pwsh | Out-String
if ($output -and $output.Trim()) {{
$output | Invoke-Expression
}}
}}
if (Get-Command $Name -ErrorAction SilentlyContinue){{
$EventArgs.Command = Get-Command $Name
$EventArgs.StopSearch = $true
}}
}}
}}
}}
}}
$current_command_not_found_function = $ExecutionContext.SessionState.InvokeCommand.CommandNotFoundAction
if ($current_command_not_found_function) {{
$ExecutionContext.SessionState.InvokeCommand.CommandNotFoundAction = [Delegate]::Combine($current_command_not_found_function, $_mise_pwsh_cmd_not_found_hook)
}}
else {{
$ExecutionContext.SessionState.InvokeCommand.CommandNotFoundAction = $_mise_pwsh_cmd_not_found_hook
}}
}}
__enable_mise_command_not_found
Remove-Item -ErrorAction SilentlyContinue -Path Function:/__enable_mise_command_not_found
}}
"#});
}
out
}
fn deactivate(&self) -> String {
formatdoc! {r#"
Remove-Item -ErrorAction Ignore function:mise
Remove-Item -ErrorAction Ignore -Path Env:/MISE_SHELL
Remove-Item -ErrorAction Ignore -Path Env:/__MISE_DIFF
Remove-Item -ErrorAction Ignore -Path Env:/__MISE_SESSION
Remove-Variable -Name __mise_pwsh_chpwd_handled -Scope Global -ErrorAction Ignore
"#}
}
fn set_env(&self, k: &str, v: &str) -> String {
let k = escape_env_name(k);
let v = escape_sq(v);
format!("${{Env:{k}}}='{v}'\n")
}
fn prepend_env(&self, k: &str, v: &str) -> String {
let k = escape_env_name(k);
let v = escape_sq(v);
format!("${{Env:{k}}}='{v}'+[IO.Path]::PathSeparator+${{env:{k}}}\n")
}
fn unset_env(&self, k: &str) -> String {
let k = escape_sq(k);
format!("Remove-Item -ErrorAction SilentlyContinue -LiteralPath 'Env:/{k}'\n")
}
}
impl Display for Pwsh {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "pwsh")
}
}
fn escape_sq(input: &str) -> Cow<'_, str> {
if input.contains('\'') {
Cow::Owned(input.replace('\'', "''"))
} else {
Cow::Borrowed(input)
}
}
fn escape_env_name(name: &str) -> Cow<'_, str> {
if name.contains(['`', '}']) {
Cow::Owned(name.replace('`', "``").replace('}', "`}"))
} else {
Cow::Borrowed(name)
}
}
#[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 pwsh = Pwsh::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!(pwsh.activate(opts));
}
#[test]
fn test_activate_command_not_found_skips_mise_itself() {
let opts = ActivateOptions {
exe: Path::new("/some/dir/mise").to_path_buf(),
flags: " --status".into(),
no_hook_env: false,
prelude: vec![],
};
let script = Pwsh::default().activate(opts);
let guard = script
.find("if ($Name -eq 'mise' -or $Name -like 'mise-*') { return }")
.expect("the command-not-found handler should skip mise's own commands");
let call = script
.find("hook-not-found -s pwsh")
.expect("the command-not-found handler should still call hook-not-found");
assert!(
guard < call,
"the guard has to run before the call it avoids"
);
}
fn command_not_found_branch(script: &str) -> &str {
let start = script
.find("hook-not-found -s pwsh")
.expect("activate should emit a command-not-found handler");
let end = script[start..]
.find("$EventArgs.StopSearch")
.expect("the handler should hand the resolved command back");
&script[start..start + end]
}
#[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 = Pwsh::default().activate(opts);
assert!(!script.contains("function Global:_mise_hook"));
assert!(script.contains("hook-not-found -s pwsh"));
assert!(script.contains("hook-env --status --force -s pwsh"));
}
#[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 = Pwsh::default().activate(opts);
let branch = command_not_found_branch(&script);
let gate = branch
.find(r#"if ($env:MISE_SHELL -eq "pwsh"){"#)
.expect("the refresh should be gated on MISE_SHELL");
let refresh = branch
.find("hook-env --status --force -s pwsh")
.expect("the handler should refresh the environment after an install");
assert!(gate < refresh, "the gate has to precede what it guards");
assert!(
!branch.lines().any(|l| l.trim() == "_mise_hook"),
"the refresh is inline: routing it through _mise_hook is what --no-hook-env breaks"
);
}
}
#[test]
fn test_set_env() {
assert_snapshot!(Pwsh::default().set_env("FOO", "1"));
}
#[test]
fn test_prepend_env() {
let pwsh = Pwsh::default();
assert_snapshot!(replace_path(&pwsh.prepend_env("PATH", "/some/dir:/2/dir")));
}
#[test]
fn test_set_env_escapes_single_quotes_only() {
let pwsh = Pwsh::default();
assert_eq!(
pwsh.set_env("HOME_ISH", r"C:\Users\O'Brien\tools"),
"${Env:HOME_ISH}='C:\\Users\\O''Brien\\tools'\n"
);
assert_eq!(
pwsh.set_env("RAW", "a`b $c \"d\" e\\f"),
"${Env:RAW}='a`b $c \"d\" e\\f'\n"
);
assert_eq!(pwsh.set_env("EMPTY", ""), "${Env:EMPTY}=''\n");
assert_eq!(pwsh.set_env("K", "'a'b'"), "${Env:K}='''a''b'''\n");
}
#[test]
fn test_prepend_env_escapes_single_quotes() {
assert_eq!(
Pwsh::default().prepend_env("PATH", r"C:\Users\O'Brien\bin"),
"${Env:PATH}='C:\\Users\\O''Brien\\bin'+[IO.Path]::PathSeparator+${env:PATH}\n"
);
}
#[test]
fn test_env_names_use_the_braced_form() {
let pwsh = Pwsh::default();
assert_eq!(pwsh.set_env("MY VAR", "x"), "${Env:MY VAR}='x'\n");
assert_eq!(pwsh.set_env("WEIRD'KEY", "x"), "${Env:WEIRD'KEY}='x'\n");
assert_eq!(pwsh.set_env("A}B", "x"), "${Env:A`}B}='x'\n");
assert_eq!(pwsh.set_env("A`B", "x"), "${Env:A``B}='x'\n");
}
#[test]
fn test_unset_env_quotes_the_path() {
assert_eq!(
Pwsh::default().unset_env("MY VAR"),
"Remove-Item -ErrorAction SilentlyContinue -LiteralPath 'Env:/MY VAR'\n"
);
assert_eq!(
Pwsh::default().unset_env("WEIRD'KEY"),
"Remove-Item -ErrorAction SilentlyContinue -LiteralPath 'Env:/WEIRD''KEY'\n"
);
}
#[test]
fn test_unset_env_does_not_glob_the_name() {
for name in ["*", "?", "PRE[FIX]"] {
let out = Pwsh::default().unset_env(name);
assert!(out.contains("-LiteralPath"), "{out}");
assert!(!out.contains(" -Path "), "{out}");
assert!(out.contains(&format!("'Env:/{name}'")), "{out}");
}
}
#[test]
fn test_activate_invokes_the_exe_through_a_single_quoted_path() {
unsafe {
std::env::remove_var("__MISE_ORIG_PATH");
std::env::remove_var("__MISE_DIFF");
}
let out = Pwsh::default().activate(ActivateOptions {
exe: Path::new(r"C:\Users\me\a$b\mise.exe").to_path_buf(),
flags: "".into(),
no_hook_env: false,
prelude: vec![],
});
assert!(out.contains(r"& 'C:\Users\me\a$b\mise.exe'"), "{out}");
assert!(!out.contains(r#"& "C:\Users\me\a$b\mise.exe""#), "{out}");
}
#[test]
fn test_unset_env() {
assert_snapshot!(Pwsh::default().unset_env("FOO"));
}
#[test]
fn test_deactivate() {
let deactivate = Pwsh::default().deactivate();
assert_snapshot!(replace_path(&deactivate));
}
#[test]
fn test_deactivate_does_not_write_to_the_error_collection() {
let out = Pwsh::default().deactivate();
assert!(!out.contains("SilentlyContinue"), "{out}");
assert!(
out.contains("Remove-Item -ErrorAction Ignore function:mise"),
"{out}"
);
assert!(
out.contains(
"Remove-Variable -Name __mise_pwsh_chpwd_handled -Scope Global -ErrorAction Ignore"
),
"{out}"
);
}
}