diff options
| author | Lucas Trzesniewski <lucas.trzesniewski@gmail.com> | 2025-10-23 22:03:39 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-10-23 13:03:39 -0700 |
| commit | 592df559da03d6b50260f9d75f194fa4ccf1ea67 (patch) | |
| tree | 27e557a85edc3be1249e338f44b0527d30a51694 /crates/atuin-dotfiles/src | |
| parent | chore: update changelog (diff) | |
| download | atuin-592df559da03d6b50260f9d75f194fa4ccf1ea67.zip | |
feat Add PowerShell support (#2543)
This adds PowerShell support 🎉
I built this script around @lzybkr's
[prototype](https://github.com/atuinsh/atuin/issues/84#issuecomment-1689168533),
so I added him as co-author (I hope that's ok). I wouldn't know where to
start without his contribution.
I'm not a PowerShell expert, so this was a nice opportunity to learn
some stuff. I think it's ok, but I would appreciate if someone more
knowledgeable in the matter could review this though.
It would be nice if other PowerShell users could test this with their
configs and report any issues. I wouldn't be surprised if there are some
remaining bugs or missing features.
Fixes #84
## Installation
If you'd like to test this, you can install the `atuin` from this PR by
running:
```powershell
cargo install --git https://github.com/ltrzesniewski/atuin.git --branch powershell-pr
```
Then, add the following to your PowerShell profile file (whose path is
in `$PROFILE`) and restart the shell:
```powershell
atuin init powershell | Out-String | Invoke-Expression
```
This requires `atuin` to be in the path and the
[PSReadLine](https://github.com/PowerShell/PSReadLine) module to be
installed, which is the case by default.
## Tests
I tested this on the following:
- PowerShell 7.4.6 / PSReadLine 2.3.5 / Windows (the latest one)
- PowerShell 7.5.1 / PSReadLine 2.3.6 / Windows (the latest one)
- PowerShell 5.1.22621.4391 / PSReadLine 2.0.0 / Windows (the one
shipped with Windows)
- PowerShell 7.4.6 / PSReadLine 2.3.5 / Ubuntu WSL (strangely, it didn't
behave exactly like the Windows version)
- PowerShell 7.5.1 / PSReadLine 2.3.6 / Ubuntu WSL
I also tested this with and without my custom [Oh My
Posh](https://ohmyposh.dev/) prompt. It works fine in both cases,
~except that since my OMP config contains `"newline": true`, my prompt
is multiline and shifts downwards by a single line on each `atuin search
-i` invocation. This can be adjusted with the
`$env:ATUIN_POWERSHELL_PROMPT_OFFSET` environment variable (e.g. I set
mine to `-1` to account for the additional prompt line).~ (this variable
is now auto-initialized).
## Checks
- [x] I am happy for maintainers to push small adjustments to this PR,
to speed up the review cycle
- [x] I have checked that there are no existing pull requests for the
same thing
---------
Co-authored-by: Jason Shirk <jasonsh@microsoft.com>
Diffstat (limited to 'crates/atuin-dotfiles/src')
| -rw-r--r-- | crates/atuin-dotfiles/src/shell.rs | 1 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/shell/powershell.rs | 169 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/store.rs | 40 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/store/var.rs | 53 |
4 files changed, 244 insertions, 19 deletions
diff --git a/crates/atuin-dotfiles/src/shell.rs b/crates/atuin-dotfiles/src/shell.rs index bd61aafa..73a9ce8c 100644 --- a/crates/atuin-dotfiles/src/shell.rs +++ b/crates/atuin-dotfiles/src/shell.rs @@ -8,6 +8,7 @@ use crate::store::AliasStore; pub mod bash; pub mod fish; +pub mod powershell; pub mod xonsh; pub mod zsh; diff --git a/crates/atuin-dotfiles/src/shell/powershell.rs b/crates/atuin-dotfiles/src/shell/powershell.rs new file mode 100644 index 00000000..1daee28b --- /dev/null +++ b/crates/atuin-dotfiles/src/shell/powershell.rs @@ -0,0 +1,169 @@ +use crate::shell::{Alias, Var}; +use crate::store::{AliasStore, var::VarStore}; +use std::path::PathBuf; + +async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { + match tokio::fs::read_to_string(path).await { + Ok(aliases) => aliases, + Err(r) => { + // we failed to read the file for some reason, but the file does exist + // fallback to generating new aliases on the fly + + store.powershell().await.unwrap_or_else(|e| { + format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) + }) + } + } +} + +async fn cached_vars(path: PathBuf, store: &VarStore) -> String { + match tokio::fs::read_to_string(path).await { + Ok(vars) => vars, + Err(r) => { + // we failed to read the file for some reason, but the file does exist + // fallback to generating new vars on the fly + + store.powershell().await.unwrap_or_else(|e| { + format!("echo 'Atuin: failed to read and generate vars: \n{r}\n{e}'",) + }) + } + } +} + +/// Return powershell dotfile config +/// +/// Do not return an error. We should not prevent the shell from starting. +/// +/// In the worst case, Atuin should not function but the shell should start correctly. +/// +/// While currently this only returns aliases, it will be extended to also return other synced dotfiles +pub async fn alias_config(store: &AliasStore) -> String { + // First try to read the cached config + let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.ps1"); + + if aliases.exists() { + return cached_aliases(aliases, store).await; + } + + if let Err(e) = store.build().await { + return format!("echo 'Atuin: failed to generate aliases: {e}'"); + } + + cached_aliases(aliases, store).await +} + +pub async fn var_config(store: &VarStore) -> String { + // First try to read the cached config + let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.ps1"); + + if vars.exists() { + return cached_vars(vars, store).await; + } + + if let Err(e) = store.build().await { + return format!("echo 'Atuin: failed to generate vars: {e}'"); + } + + cached_vars(vars, store).await +} + +pub fn format_alias(alias: &Alias) -> String { + // Set-Alias doesn't support adding implicit arguments, so use a function. + // See https://github.com/PowerShell/PowerShell/issues/12962 + + let mut result = secure_command(&format!( + "function {} {{\n {}{} @args\n}}", + alias.name, + if alias.value.starts_with(['"', '\'']) { + "& " + } else { + "" + }, + alias.value + )); + + // This makes the file layout prettier + result.insert(0, '\n'); + result +} + +pub fn format_var(var: &Var) -> String { + secure_command(&format!( + "${}{} = '{}'", + if var.export { "env:" } else { "" }, + var.name, + var.value.replace("'", "''") + )) +} + +/// Wraps the given command in an Invoke-Expression to ensure the outer script is not halted +/// if the inner command contains a syntax error. +fn secure_command(command: &str) -> String { + format!( + "Invoke-Expression -ErrorAction Continue -Command '{}'\n", + command.replace("'", "''") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aliases() { + assert_eq!( + format_alias(&Alias { + name: "gp".to_string(), + value: "git push".to_string(), + }), + "\n".to_string() + + &secure_command( + "function gp { + git push @args +}" + ) + ); + + assert_eq!( + format_alias(&Alias { + name: "spc".to_string(), + value: "\"path with spaces\" arg".to_string(), + }), + "\n".to_string() + + &secure_command( + "function spc { + & \"path with spaces\" arg @args +}" + ) + ); + } + + #[test] + fn vars() { + assert_eq!( + format_var(&Var { + name: "FOO".to_owned(), + value: "bar 'baz'".to_owned(), + export: true, + }), + secure_command("$env:FOO = 'bar ''baz'''") + ); + + assert_eq!( + format_var(&Var { + name: "TEST".to_owned(), + value: "1".to_owned(), + export: false, + }), + secure_command("$TEST = '1'") + ); + } + + #[test] + fn invoke_expression() { + assert_eq!( + secure_command("echo 'foo'"), + "Invoke-Expression -ErrorAction Continue -Command 'echo ''foo'''\n" + ) + } +} diff --git a/crates/atuin-dotfiles/src/store.rs b/crates/atuin-dotfiles/src/store.rs index 01316b4e..17597065 100644 --- a/crates/atuin-dotfiles/src/store.rs +++ b/crates/atuin-dotfiles/src/store.rs @@ -142,7 +142,20 @@ impl AliasStore { pub async fn posix(&self) -> Result<String> { let aliases = self.aliases().await?; + Ok(Self::format_posix(&aliases)) + } + + pub async fn xonsh(&self) -> Result<String> { + let aliases = self.aliases().await?; + Ok(Self::format_xonsh(&aliases)) + } + pub async fn powershell(&self) -> Result<String> { + let aliases = self.aliases().await?; + Ok(Self::format_powershell(&aliases)) + } + + fn format_posix(aliases: &[Alias]) -> String { let mut config = String::new(); for alias in aliases { @@ -153,28 +166,39 @@ impl AliasStore { config.push_str(&format!("alias {}='{}'\n", alias.name, value)); } - Ok(config) + config } - pub async fn xonsh(&self) -> Result<String> { - let aliases = self.aliases().await?; - + fn format_xonsh(aliases: &[Alias]) -> String { let mut config = String::new(); for alias in aliases { config.push_str(&format!("aliases['{}'] ='{}'\n", alias.name, alias.value)); } - Ok(config) + config + } + + fn format_powershell(aliases: &[Alias]) -> String { + let mut config = String::new(); + + for alias in aliases { + config.push_str(&crate::shell::powershell::format_alias(alias)); + } + + config } pub async fn build(&self) -> Result<()> { let dir = atuin_common::utils::dotfiles_cache_dir(); tokio::fs::create_dir_all(dir.clone()).await?; + let aliases = self.aliases().await?; + // Build for all supported shells - let posix = self.posix().await?; - let xonsh = self.xonsh().await?; + let posix = Self::format_posix(&aliases); + let xonsh = Self::format_xonsh(&aliases); + let powershell = Self::format_powershell(&aliases); // All the same contents, maybe optimize in the future or perhaps there will be quirks // per-shell @@ -183,11 +207,13 @@ impl AliasStore { let bash = dir.join("aliases.bash"); let fish = dir.join("aliases.fish"); let xsh = dir.join("aliases.xsh"); + let ps1 = dir.join("aliases.ps1"); tokio::fs::write(zsh, &posix).await?; tokio::fs::write(bash, &posix).await?; tokio::fs::write(fish, &posix).await?; tokio::fs::write(xsh, &xonsh).await?; + tokio::fs::write(ps1, &powershell).await?; Ok(()) } diff --git a/crates/atuin-dotfiles/src/store/var.rs b/crates/atuin-dotfiles/src/store/var.rs index 402ba684..9d25b85d 100644 --- a/crates/atuin-dotfiles/src/store/var.rs +++ b/crates/atuin-dotfiles/src/store/var.rs @@ -169,7 +169,25 @@ impl VarStore { pub async fn xonsh(&self) -> Result<String> { let env = self.vars().await?; + Ok(Self::format_xonsh(&env)) + } + pub async fn fish(&self) -> Result<String> { + let env = self.vars().await?; + Ok(Self::format_fish(&env)) + } + + pub async fn posix(&self) -> Result<String> { + let env = self.vars().await?; + Ok(Self::format_posix(&env)) + } + + pub async fn powershell(&self) -> Result<String> { + let env = self.vars().await?; + Ok(Self::format_powershell(&env)) + } + + fn format_xonsh(env: &[Var]) -> String { let mut config = String::new(); for env in env { @@ -177,12 +195,10 @@ impl VarStore { config.push_str(&format!("${}={}\n", env.name, escaped_value)); } - Ok(config) + config } - pub async fn fish(&self) -> Result<String> { - let env = self.vars().await?; - + fn format_fish(env: &[Var]) -> String { let mut config = String::new(); for env in env { @@ -190,12 +206,10 @@ impl VarStore { config.push_str(&format!("set -gx {} {}\n", env.name, escaped_value)); } - Ok(config) + config } - pub async fn posix(&self) -> Result<String> { - let env = self.vars().await?; - + fn format_posix(env: &[Var]) -> String { let mut config = String::new(); for env in env { @@ -207,17 +221,30 @@ impl VarStore { } } - Ok(config) + config + } + + fn format_powershell(env: &[Var]) -> String { + let mut config = String::new(); + + for var in env { + config.push_str(&crate::shell::powershell::format_var(var)); + } + + config } pub async fn build(&self) -> Result<()> { let dir = atuin_common::utils::dotfiles_cache_dir(); tokio::fs::create_dir_all(dir.clone()).await?; + let env = self.vars().await?; + // Build for all supported shells - let posix = self.posix().await?; - let xonsh = self.xonsh().await?; - let fsh = self.fish().await?; + let posix = Self::format_posix(&env); + let xonsh = Self::format_xonsh(&env); + let fsh = Self::format_fish(&env); + let powershell = Self::format_powershell(&env); // All the same contents, maybe optimize in the future or perhaps there will be quirks // per-shell @@ -226,11 +253,13 @@ impl VarStore { let bash = dir.join("vars.bash"); let fish = dir.join("vars.fish"); let xsh = dir.join("vars.xsh"); + let ps1 = dir.join("vars.ps1"); tokio::fs::write(zsh, &posix).await?; tokio::fs::write(bash, &posix).await?; tokio::fs::write(fish, &fsh).await?; tokio::fs::write(xsh, &xonsh).await?; + tokio::fs::write(ps1, &powershell).await?; Ok(()) } |
