diff --git a/crates/atuin/src/command/client/init.rs b/crates/atuin/src/command/client/init.rs index cbbfc944..711051bc 100644 --- a/crates/atuin/src/command/client/init.rs +++ b/crates/atuin/src/command/client/init.rs @@ -11,6 +11,7 @@ use eyre::{Result, WrapErr}; mod bash; mod fish; +mod nu; mod powershell; mod xonsh; mod zsh; @@ -50,75 +51,36 @@ pub enum Shell { PowerShell, } -impl Cmd { - fn init_nu(&self, _tmux: &Tmux) { - // TODO: tmux popup for Nu - println!("{}", crate::shell::NU); - - if std::env::var("ATUIN_NOBIND").is_err() { - const BIND_CTRL_R: &str = r"$env.config = ( - $env.config | upsert keybindings ( - $env.config.keybindings - | append { - name: atuin - modifier: control - keycode: char_r - mode: [emacs, vi_normal, vi_insert] - event: { send: executehostcommand cmd: (_atuin_search_cmd) } - } - ) -)"; - const BIND_UP_ARROW: &str = r" -$env.config = ( - $env.config | upsert keybindings ( - $env.config.keybindings - | append { - name: atuin - modifier: none - keycode: up - mode: [emacs, vi_normal, vi_insert] - event: { - until: [ - {send: menuup} - {send: executehostcommand cmd: (_atuin_search_cmd '--shell-up-key-binding') } - ] - } - } - ) -) -"; - if !self.disable_ctrl_r { - println!("{BIND_CTRL_R}"); - } - if !self.disable_up_arrow { - println!("{BIND_UP_ARROW}"); - } - } - } +struct StaticInitOptions<'a> { + pub enable_up_arrow: bool, + pub enable_ctrl_r: bool, + #[cfg_attr(not(feature = "ai"), allow(dead_code))] + pub enable_ai: bool, + pub tmux: &'a Tmux, +} +impl Cmd { fn static_init(&self, settings: &Settings) { - let tmux = &settings.tmux; - - let disable_ai = self.disable_ai || matches!(settings.ai.enabled, Some(false)); + let options = self.to_options(settings); match self.shell { Shell::Zsh => { - zsh::init_static(self.disable_up_arrow, self.disable_ctrl_r, disable_ai, tmux); + zsh::init_static(&options); } Shell::Bash => { - bash::init_static(self.disable_up_arrow, self.disable_ctrl_r, disable_ai, tmux); + bash::init_static(&options); } Shell::Fish => { - fish::init_static(self.disable_up_arrow, self.disable_ctrl_r, disable_ai, tmux); + fish::init_static(&options); } Shell::Nu => { - self.init_nu(tmux); + nu::init_static(&options); } Shell::Xonsh => { - xonsh::init_static(self.disable_up_arrow, self.disable_ctrl_r, tmux); + xonsh::init_static(&options); } Shell::PowerShell => { - powershell::init_static(self.disable_up_arrow, self.disable_ctrl_r, tmux); + powershell::init_static(&options); } } } @@ -135,68 +97,39 @@ $env.config = ( let alias_store = AliasStore::new(sqlite_store.clone(), host_id, encryption_key); let var_store = VarStore::new(sqlite_store.clone(), host_id, encryption_key); - let disable_ai = self.disable_ai || matches!(settings.ai.enabled, Some(false)); + let options = self.to_options(settings); match self.shell { Shell::Zsh => { - zsh::init( - alias_store, - var_store, - self.disable_up_arrow, - self.disable_ctrl_r, - disable_ai, - &settings.tmux, - ) - .await?; + zsh::init(alias_store, var_store, &options).await?; } Shell::Bash => { - bash::init( - alias_store, - var_store, - self.disable_up_arrow, - self.disable_ctrl_r, - disable_ai, - &settings.tmux, - ) - .await?; + bash::init(alias_store, var_store, &options).await?; } Shell::Fish => { - fish::init( - alias_store, - var_store, - self.disable_up_arrow, - self.disable_ctrl_r, - disable_ai, - &settings.tmux, - ) - .await?; + fish::init(alias_store, var_store, &options).await?; } - Shell::Nu => self.init_nu(&settings.tmux), + Shell::Nu => nu::init_static(&options), Shell::Xonsh => { - xonsh::init( - alias_store, - var_store, - self.disable_up_arrow, - self.disable_ctrl_r, - &settings.tmux, - ) - .await?; + xonsh::init(alias_store, var_store, &options).await?; } Shell::PowerShell => { - powershell::init( - alias_store, - var_store, - self.disable_up_arrow, - self.disable_ctrl_r, - &settings.tmux, - ) - .await?; + powershell::init(alias_store, var_store, &options).await?; } } Ok(()) } + fn to_options<'a>(&self, settings: &'a Settings) -> StaticInitOptions<'a> { + StaticInitOptions { + enable_up_arrow: !self.disable_up_arrow, + enable_ctrl_r: !self.disable_ctrl_r, + enable_ai: !self.disable_ai && settings.ai.enabled.unwrap_or(true), + tmux: &settings.tmux, + } + } + pub async fn run(self, settings: &Settings) -> Result<()> { if !settings.paths_ok() { eprintln!( diff --git a/crates/atuin/src/command/client/init/bash.rs b/crates/atuin/src/command/client/init/bash.rs index fb6a1e9b..ed867922 100644 --- a/crates/atuin/src/command/client/init/bash.rs +++ b/crates/atuin/src/command/client/init/bash.rs @@ -1,47 +1,69 @@ +use super::StaticInitOptions; use crate::shell::BASH; use atuin_client::settings::Tmux; use atuin_dotfiles::store::{AliasStore, var::VarStore}; use eyre::Result; +use std::io::{self, Write}; -fn print_tmux_config(tmux: &Tmux) { +fn write_tmux_config(writer: &mut W, tmux: &Tmux) -> io::Result<()> { if tmux.enabled { - println!("export ATUIN_TMUX_POPUP_WIDTH='{}'", tmux.width); - println!("export ATUIN_TMUX_POPUP_HEIGHT='{}'", tmux.height); + writeln!(writer, "export ATUIN_TMUX_POPUP_WIDTH='{}'", tmux.width)?; + writeln!(writer, "export ATUIN_TMUX_POPUP_HEIGHT='{}'", tmux.height) } else { - println!("export ATUIN_TMUX_POPUP=false"); + writeln!(writer, "export ATUIN_TMUX_POPUP=false") } } -pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux) { +fn write_static_init(writer: &mut W, options: &StaticInitOptions<'_>) -> io::Result<()> { let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() { (false, false) } else { - (!disable_ctrl_r, !disable_up_arrow) + (options.enable_ctrl_r, options.enable_up_arrow) }; - println!("{} && {{", BASH.include_guard); - print_tmux_config(tmux); - println!("__atuin_bind_ctrl_r={bind_ctrl_r}"); - println!("__atuin_bind_up_arrow={bind_up_arrow}"); - println!("{}", BASH.main); + writeln!(writer, "{} && {{", BASH.include_guard)?; + + if std::env::var_os("ATUIN_NO_BUILTIN_PREEXEC").is_none_or(|s| s.is_empty()) { + writeln!( + writer, + "# Set ATUIN_NO_BUILTIN_PREEXEC=1 to disable loading bash-preexec" + )?; + writeln!(writer, "__atuin_load_builtin_preexec() {{")?; + for line in BASH.preexec.lines() { + writeln!(writer, " {line}")?; + } + writeln!(writer, "}}")?; + } + + write_tmux_config(writer, options.tmux)?; + writeln!(writer, "__atuin_bind_ctrl_r={bind_ctrl_r}")?; + writeln!(writer, "__atuin_bind_up_arrow={bind_up_arrow}")?; + writeln!(writer, "{}", BASH.main)?; #[cfg(feature = "ai")] - if !disable_ai { + if options.enable_ai { let bind_ai = atuin_ai::commands::init::generate_bash_integration(); - println!("{bind_ai}"); + writeln!(writer, "{bind_ai}")?; + } + + writeln!(writer, "}}") // end include guard +} + +pub fn init_static(options: &StaticInitOptions<'_>) { + if let Err(e) = write_static_init(&mut io::stdout().lock(), options) { + // This function used to use `println!`, which panics on write failure with this same + // message. Using a locked `Stdout` object is faster, but `write!` returns an error rather + // than panicking, so we manually panic here to keep the same behavior. + panic!("failed printing to stdout: {e}"); } - println!("}}"); } pub async fn init( aliases: AliasStore, vars: VarStore, - disable_up_arrow: bool, - disable_ctrl_r: bool, - disable_ai: bool, - tmux: &Tmux, + options: &StaticInitOptions<'_>, ) -> Result<()> { - init_static(disable_up_arrow, disable_ctrl_r, disable_ai, tmux); + init_static(options); let aliases = atuin_dotfiles::shell::bash::alias_config(&aliases).await; let vars = atuin_dotfiles::shell::bash::var_config(&vars).await; diff --git a/crates/atuin/src/command/client/init/fish.rs b/crates/atuin/src/command/client/init/fish.rs index e8f08f79..5c7d4bc0 100644 --- a/crates/atuin/src/command/client/init/fish.rs +++ b/crates/atuin/src/command/client/init/fish.rs @@ -1,3 +1,4 @@ +use super::StaticInitOptions; use atuin_client::settings::Tmux; use atuin_dotfiles::store::{AliasStore, var::VarStore}; use eyre::Result; @@ -13,34 +14,33 @@ fn print_tmux_config(tmux: &Tmux) { fn print_bindings( indent: &str, - disable_up_arrow: bool, - disable_ctrl_r: bool, + options: &StaticInitOptions<'_>, bind_ctrl_r: &str, bind_up_arrow: &str, bind_ctrl_r_ins: &str, bind_up_arrow_ins: &str, ) { - if !disable_ctrl_r { + if options.enable_ctrl_r { println!("{indent}{bind_ctrl_r}"); } - if !disable_up_arrow { + if options.enable_up_arrow { println!("{indent}{bind_up_arrow}"); } println!("{indent}if bind -M insert >/dev/null 2>&1"); - if !disable_ctrl_r { + if options.enable_ctrl_r { println!("{indent}{indent}{bind_ctrl_r_ins}"); } - if !disable_up_arrow { + if options.enable_up_arrow { println!("{indent}{indent}{bind_up_arrow_ins}"); } println!("{indent}end"); } -pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux) { +pub fn init_static(options: &StaticInitOptions<'_>) { let indent = " ".repeat(4); - print_tmux_config(tmux); + print_tmux_config(options.tmux); println!("{}", crate::shell::FISH); if std::env::var("ATUIN_NOBIND").is_err() { @@ -50,8 +50,7 @@ pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: boo // instead we can use key names and modifiers directly. print_bindings( &indent, - disable_up_arrow, - disable_ctrl_r, + options, "bind ctrl-r _atuin_search", "bind up _atuin_bind_up", "bind -M insert ctrl-r _atuin_search", @@ -63,8 +62,7 @@ pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: boo // We keep these for compatibility with fish 3.x print_bindings( &indent, - disable_up_arrow, - disable_ctrl_r, + options, r"bind \cr _atuin_search", &[ r"bind -k up _atuin_bind_up", @@ -84,7 +82,7 @@ pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: boo println!("end"); #[cfg(feature = "ai")] - if !disable_ai { + if options.enable_ai { let bind_ai = atuin_ai::commands::init::generate_fish_integration(); println!("{bind_ai}"); } @@ -94,12 +92,9 @@ pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: boo pub async fn init( aliases: AliasStore, vars: VarStore, - disable_up_arrow: bool, - disable_ctrl_r: bool, - disable_ai: bool, - tmux: &Tmux, + options: &StaticInitOptions<'_>, ) -> Result<()> { - init_static(disable_up_arrow, disable_ctrl_r, disable_ai, tmux); + init_static(options); let aliases = atuin_dotfiles::shell::fish::alias_config(&aliases).await; let vars = atuin_dotfiles::shell::fish::var_config(&vars).await; diff --git a/crates/atuin/src/command/client/init/nu.rs b/crates/atuin/src/command/client/init/nu.rs new file mode 100644 index 00000000..7bb264b1 --- /dev/null +++ b/crates/atuin/src/command/client/init/nu.rs @@ -0,0 +1,46 @@ +use super::StaticInitOptions; + +const BIND_CTRL_R: &str = r"$env.config = ( + $env.config | upsert keybindings ( + $env.config.keybindings + | append { + name: atuin + modifier: control + keycode: char_r + mode: [emacs, vi_normal, vi_insert] + event: { send: executehostcommand cmd: (_atuin_search_cmd) } + } + ) +)"; + +const BIND_UP_ARROW: &str = r"$env.config = ( + $env.config | upsert keybindings ( + $env.config.keybindings + | append { + name: atuin + modifier: none + keycode: up + mode: [emacs, vi_normal, vi_insert] + event: { + until: [ + {send: menuup} + {send: executehostcommand cmd: (_atuin_search_cmd '--shell-up-key-binding') } + ] + } + } + ) +)"; + +pub fn init_static(options: &StaticInitOptions<'_>) { + // TODO: tmux popup for Nu + println!("{}", crate::shell::NU); + + if std::env::var("ATUIN_NOBIND").is_err() { + if options.enable_ctrl_r { + println!("{BIND_CTRL_R}"); + } + if options.enable_up_arrow { + println!("{BIND_UP_ARROW}"); + } + } +} diff --git a/crates/atuin/src/command/client/init/powershell.rs b/crates/atuin/src/command/client/init/powershell.rs index d1e86740..aa2dd0ad 100644 --- a/crates/atuin/src/command/client/init/powershell.rs +++ b/crates/atuin/src/command/client/init/powershell.rs @@ -1,11 +1,11 @@ -use atuin_client::settings::Tmux; +use super::StaticInitOptions; use atuin_dotfiles::store::{AliasStore, var::VarStore}; -pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, _tmux: &Tmux) { +pub fn init_static(options: &StaticInitOptions<'_>) { let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() { (false, false) } else { - (!disable_ctrl_r, !disable_up_arrow) + (options.enable_ctrl_r, options.enable_up_arrow) }; // TODO: tmux popup for Powershell @@ -20,11 +20,9 @@ pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, _tmux: &Tmux) { pub async fn init( aliases: AliasStore, vars: VarStore, - disable_up_arrow: bool, - disable_ctrl_r: bool, - tmux: &Tmux, + options: &StaticInitOptions<'_>, ) -> eyre::Result<()> { - init_static(disable_up_arrow, disable_ctrl_r, tmux); + init_static(options); let aliases = atuin_dotfiles::shell::powershell::alias_config(&aliases).await; let vars = atuin_dotfiles::shell::powershell::var_config(&vars).await; diff --git a/crates/atuin/src/command/client/init/xonsh.rs b/crates/atuin/src/command/client/init/xonsh.rs index 983223d1..d096530e 100644 --- a/crates/atuin/src/command/client/init/xonsh.rs +++ b/crates/atuin/src/command/client/init/xonsh.rs @@ -1,12 +1,12 @@ -use atuin_client::settings::Tmux; +use super::StaticInitOptions; use atuin_dotfiles::store::{AliasStore, var::VarStore}; use eyre::Result; -pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, _tmux: &Tmux) { +pub fn init_static(options: &StaticInitOptions<'_>) { let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() { (false, false) } else { - (!disable_ctrl_r, !disable_up_arrow) + (options.enable_ctrl_r, options.enable_up_arrow) }; // TODO: tmux popup for xonsh @@ -24,11 +24,9 @@ pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, _tmux: &Tmux) { pub async fn init( aliases: AliasStore, vars: VarStore, - disable_up_arrow: bool, - disable_ctrl_r: bool, - tmux: &Tmux, + options: &StaticInitOptions<'_>, ) -> Result<()> { - init_static(disable_up_arrow, disable_ctrl_r, tmux); + init_static(options); let aliases = atuin_dotfiles::shell::xonsh::alias_config(&aliases).await; let vars = atuin_dotfiles::shell::xonsh::var_config(&vars).await; diff --git a/crates/atuin/src/command/client/init/zsh.rs b/crates/atuin/src/command/client/init/zsh.rs index 3d0c4e40..7f42e0b2 100644 --- a/crates/atuin/src/command/client/init/zsh.rs +++ b/crates/atuin/src/command/client/init/zsh.rs @@ -1,3 +1,4 @@ +use super::StaticInitOptions; use atuin_client::settings::Tmux; use atuin_dotfiles::store::{AliasStore, var::VarStore}; use eyre::Result; @@ -11,8 +12,8 @@ fn print_tmux_config(tmux: &Tmux) { } } -pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux) { - print_tmux_config(tmux); +pub fn init_static(options: &StaticInitOptions<'_>) { + print_tmux_config(options.tmux); println!("{}", crate::shell::ZSH); if std::env::var("ATUIN_NOBIND").is_err() { @@ -28,15 +29,15 @@ bindkey -M vicmd '^[OA' atuin-up-search-vicmd bindkey -M viins '^[OA' atuin-up-search-viins bindkey -M vicmd 'k' atuin-up-search-vicmd"; - if !disable_ctrl_r { + if options.enable_ctrl_r { println!("{BIND_CTRL_R}"); } - if !disable_up_arrow { + if options.enable_up_arrow { println!("{BIND_UP_ARROW}"); } #[cfg(feature = "ai")] - if !disable_ai { + if options.enable_ai { let bind_ai = atuin_ai::commands::init::generate_zsh_integration(); println!("{bind_ai}"); @@ -47,12 +48,9 @@ bindkey -M vicmd 'k' atuin-up-search-vicmd"; pub async fn init( aliases: AliasStore, vars: VarStore, - disable_up_arrow: bool, - disable_ctrl_r: bool, - disable_ai: bool, - tmux: &Tmux, + options: &StaticInitOptions<'_>, ) -> Result<()> { - init_static(disable_up_arrow, disable_ctrl_r, disable_ai, tmux); + init_static(options); let aliases = atuin_dotfiles::shell::zsh::alias_config(&aliases).await; let vars = atuin_dotfiles::shell::zsh::var_config(&vars).await; diff --git a/crates/atuin/src/shell.rs b/crates/atuin/src/shell.rs index 869f3640..c8ae4c8a 100644 --- a/crates/atuin/src/shell.rs +++ b/crates/atuin/src/shell.rs @@ -1,17 +1,25 @@ +macro_rules! include_trimmed { + ($path:expr) => { + include_str!($path).trim_ascii_end() + }; +} + macro_rules! include_shell { - ($path:literal) => { - include_str!(concat!("shell/", $path)).trim_ascii_end() + ($path:expr) => { + include_trimmed!(concat!("shell/", $path)) }; } pub struct Bash<'a> { pub include_guard: &'a str, pub main: &'a str, + pub preexec: &'a str, } pub const BASH: Bash<'_> = Bash { include_guard: include_shell!("atuin.bash.d/include-guard.bash"), main: include_shell!("atuin.bash"), + preexec: include_trimmed!("../../../vendor/bash-preexec/bash-preexec.sh"), }; pub const FISH: &str = include_shell!("atuin.fish"); diff --git a/crates/atuin/src/shell/atuin.bash b/crates/atuin/src/shell/atuin.bash index bab1110a..8b25baa8 100644 --- a/crates/atuin/src/shell/atuin.bash +++ b/crates/atuin/src/shell/atuin.bash @@ -708,3 +708,14 @@ if [[ $__atuin_bind_up_arrow == true ]]; then atuin-bind -m vi-command '\eOA' atuin-up-search-vicmd atuin-bind -m vi-command 'k' atuin-up-search-vicmd fi + +if command -v __atuin_load_builtin_preexec > /dev/null; then + if [[ -z ${ATUIN_NO_BUILTIN_PREEXEC-} ]]; then + __atuin_update_preexec_backend + if [[ $ATUIN_PREEXEC_BACKEND == *:unknown ]]; then + __atuin_load_builtin_preexec + fi + fi + # Free the function from memory + unset -f __atuin_load_builtin_preexec +fi diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 00000000..d51ddb53 --- /dev/null +++ b/vendor/README.md @@ -0,0 +1,16 @@ +This directory contains vendored repositories. + +Use [vendor.sh](vendor.sh) to manage vendored repositories: + +``` +Usage: + vendor.sh add [name] + vendor.sh list + vendor.sh update + + specifies which branch/tag/commit to check out in the vendored +repository. + + is the name of the subdirectory in 'vendor/'. By default, it is +inferred from the repository URL. +``` diff --git a/vendor/bash-preexec/.github/workflows/bats.yaml b/vendor/bash-preexec/.github/workflows/bats.yaml new file mode 100644 index 00000000..efbe12ef --- /dev/null +++ b/vendor/bash-preexec/.github/workflows/bats.yaml @@ -0,0 +1,19 @@ +name: Bats tests +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + bash-version: ['3.2', '4.4', '5.0', '5.3'] + container: + image: bash:${{ matrix.bash-version }} + steps: + - name: Check out repository + uses: actions/checkout@v5 + - name: Install Bats + run: apk add --no-cache bats + - name: Run tests + run: bats test diff --git a/vendor/bash-preexec/LICENSE.md b/vendor/bash-preexec/LICENSE.md new file mode 100644 index 00000000..b4521b39 --- /dev/null +++ b/vendor/bash-preexec/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2017 Ryan Caloras and contributors (see https://github.com/rcaloras/bash-preexec) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/bash-preexec/README.md b/vendor/bash-preexec/README.md new file mode 100644 index 00000000..263cf6e1 --- /dev/null +++ b/vendor/bash-preexec/README.md @@ -0,0 +1,116 @@ +[![Build Status](https://github.com/rcaloras/bash-preexec/actions/workflows/bats.yaml/badge.svg)](https://github.com/rcaloras/bash-preexec/actions/) +[![GitHub version](https://badge.fury.io/gh/rcaloras%2Fbash-preexec.svg)](https://badge.fury.io/gh/rcaloras%2Fbash-preexec) + +Bash-Preexec +============ + +**preexec** and **precmd** hook functions for Bash 3.1+ in the style of Zsh. They aim to emulate the behavior [as described for Zsh](http://zsh.sourceforge.net/Doc/Release/Functions.html#Hook-Functions). + +Bashhub Logo + +This project is currently being used in production by [Bashhub](https://github.com/rcaloras/bashhub-client), [iTerm2](https://github.com/gnachman/iTerm2), and [Ghostty](https://ghostty.org/). Hype! + +## Quick Start +```bash +# Pull down our file from GitHub and write it to your home directory as a hidden file. +curl https://raw.githubusercontent.com/rcaloras/bash-preexec/master/bash-preexec.sh -o ~/.bash-preexec.sh +# Source our file to bring it into our environment +source ~/.bash-preexec.sh +# Define a couple functions. +preexec() { echo "just typed $1"; } +precmd() { echo "printing the prompt"; } +``` + +## Install +You'll want to pull down the file and add it to your bash profile/configuration (i.e ~/.bashrc, ~/.profile, ~/.bash_profile, etc). **It must be the last thing imported in your bash profile.** +```bash +# Pull down our file from GitHub and write it to your home directory as a hidden file. +curl https://raw.githubusercontent.com/rcaloras/bash-preexec/master/bash-preexec.sh -o ~/.bash-preexec.sh +# Source our file at the end of our bash profile (e.g. ~/.bashrc, ~/.profile, or ~/.bash_profile) +echo '[[ -f ~/.bash-preexec.sh ]] && source ~/.bash-preexec.sh' >> ~/.bashrc +``` + +## Usage +Two functions **preexec** and **precmd** can now be defined and they'll be automatically invoked by bash-preexec if they exist. + +* `preexec` Executed just after a command has been read and is about to be executed. The string that the user typed is passed as the first argument. +* `precmd` Executed just before each prompt. Equivalent to PROMPT_COMMAND, but more flexible and resilient. +```bash +source ~/.bash-preexec.sh +preexec() { echo "just typed $1"; } +precmd() { echo "printing the prompt"; } +``` +Should output something like: +``` +elementz@Kashmir:~/git/bash-preexec (master)$ ls +just typed ls +bash-preexec.sh README.md test +printing the prompt +``` +#### Function Arrays +You can also define functions to be invoked by appending them to two different arrays. This is great if you want to have many functions invoked for either hook. Both preexec and precmd functions are added to these by default and don't need to be added manually. +* `$preexec_functions` Array of functions invoked by preexec. +* `$precmd_functions` Array of functions invoked by precmd. + +#### preexec +```bash +# Define some function to use preexec +preexec_hello_world() { echo "You just entered $1"; } +# Add it to the array of functions to be invoked each time. +preexec_functions+=(preexec_hello_world) +``` + +#### precmd +```bash +precmd_hello_world() { echo "This is invoked before the prompt is displayed"; } +precmd_functions+=(precmd_hello_world) +``` + +You can also define multiple functions to be invoked like so. + +```bash +precmd_hello_one() { echo "This is invoked on precmd first"; } +precmd_hello_two() { echo "This is invoked on precmd second"; } +precmd_functions+=(precmd_hello_one) +precmd_functions+=(precmd_hello_two) +``` + +You can check the functions set for each by echoing its contents. + +```bash +echo ${preexec_functions[@]} +echo ${precmd_functions[@]} +``` + +## Subshells +bash-preexec does not support invoking preexec() for subshells by default. It must be enabled by setting +`__bp_enable_subshells`. +```bash +# Enable experimental subshell support +export __bp_enable_subshells="true" +``` +This is disabled by default due to buggy situations related to to `functrace` and Bash's `DEBUG trap`. See [Issue #25](https://github.com/rcaloras/bash-preexec/issues/25) + +## Library authors +If you want to detect bash-preexec in your library (for example, to add hooks to `preexec_functions` when available), use the Bash variable `bash_preexec_imported`: + +```bash +if [[ -n "${bash_preexec_imported:-}" ]]; then + echo "Bash-preexec is loaded." +fi +``` + +## Tests +You can run tests using [Bats](https://github.com/bats-core/bats-core). +```bash +bats test +``` +Should output something like: +``` +elementz@Kashmir:~/git/bash-preexec(master)$ bats test + ✓ No functions defined for preexec should simply return + ✓ precmd should execute a function once + ✓ preexec should execute a function with the last command in our history + ✓ preexec should execute multiple functions in the order added to their arrays + ✓ preecmd should execute multiple functions in the order added to their arrays +``` diff --git a/vendor/bash-preexec/bash-preexec.sh b/vendor/bash-preexec/bash-preexec.sh new file mode 100644 index 00000000..a80ee6e4 --- /dev/null +++ b/vendor/bash-preexec/bash-preexec.sh @@ -0,0 +1,566 @@ +# bash-preexec.sh -- Bash support for ZSH-like 'preexec' and 'precmd' functions. +# https://github.com/rcaloras/bash-preexec +# +# +# 'preexec' functions are executed before each interactive command is +# executed, with the interactive command as its argument. The 'precmd' +# function is executed before each prompt is displayed. +# +# Author: Ryan Caloras (ryan@bashhub.com) +# Forked from Original Author: Glyph Lefkowitz +# +# V0.6.0 +# + +# General Usage: +# +# 1. Source this file at the end of your bash profile so as not to interfere +# with anything else that's using PROMPT_COMMAND. +# +# 2. Add any precmd or preexec functions by appending them to their arrays: +# e.g. +# precmd_functions+=(my_precmd_function) +# precmd_functions+=(some_other_precmd_function) +# +# preexec_functions+=(my_preexec_function) +# +# 3. Consider changing anything using the DEBUG trap or PROMPT_COMMAND +# to use preexec and precmd instead. Preexisting usages will be +# preserved, but doing so manually may be less surprising. +# +# Note: This module requires two Bash features which you must not otherwise be +# using: the "DEBUG" trap, and the "PROMPT_COMMAND" variable. If you override +# either of these after bash-preexec has been installed it will most likely break. + +# Tell shellcheck what kind of file this is. +# shellcheck shell=bash + +# Make sure this is bash that's running and return otherwise. +# Use POSIX syntax for this line: +if [ -z "${BASH_VERSION-}" ]; then + return 1 +fi + +# We only support Bash 3.1+. +# Note: BASH_VERSINFO is first available in Bash-2.0. +if [[ -z "${BASH_VERSINFO-}" ]] || (( BASH_VERSINFO[0] < 3 || (BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 1) )); then + return 1 +fi + +# Avoid duplicate inclusion +if [[ -n "${bash_preexec_imported:-}" || -n "${__bp_imported:-}" ]]; then + return 0 +fi +bash_preexec_imported="defined" + +# WARNING: This variable is no longer used and should not be relied upon. +# Use ${bash_preexec_imported} instead. +# shellcheck disable=SC2034 +__bp_imported="${bash_preexec_imported}" + +# Should be available to each precmd and preexec +# functions, should they want it. $? and $_ are available as $? and $_, but +# $PIPESTATUS is available only in a copy, $BP_PIPESTATUS. +# TODO: Figure out how to restore PIPESTATUS before each precmd or preexec +# function. +__bp_last_ret_value="$?" +BP_PIPESTATUS=("${PIPESTATUS[@]}") +__bp_last_argument_prev_command="$_" + +__bp_inside_precmd=0 +__bp_inside_preexec=0 + +# Initial PROMPT_COMMAND string that is removed from PROMPT_COMMAND post __bp_install +# shellcheck disable=SC2016 +__bp_install_string='__bp_install "$_"' + +# Fails if any of the given variables are readonly +# Reference https://stackoverflow.com/a/4441178 +__bp_require_not_readonly() { + local var + for var; do + if ! ( unset "$var" 2> /dev/null ); then + echo "bash-preexec requires write access to ${var}" >&2 + return 1 + fi + done +} + +# Remove ignorespace and or replace ignoreboth from HISTCONTROL +# so we can accurately invoke preexec with a command from our +# history even if it starts with a space. +__bp_adjust_histcontrol() { + local histcontrol + histcontrol="${HISTCONTROL:-}" + histcontrol="${histcontrol//ignorespace}" + # Replace ignoreboth with ignoredups + if [[ "$histcontrol" == *"ignoreboth"* ]]; then + histcontrol="ignoredups:${histcontrol//ignoreboth}" + fi + export HISTCONTROL="$histcontrol" +} + +# This variable describes whether we are currently in "interactive mode"; +# i.e. whether this shell has just executed a prompt and is waiting for user +# input. It documents whether the current command invoked by the trace hook is +# run interactively by the user; it's set immediately after the prompt hook, +# and unset as soon as the trace hook is run. +__bp_preexec_interactive_mode="" + +# These global arrays are used to add functions to be run before, or after, +# prompts. Note that Bash < 4.2 does not have the "-g" option of the "declare" +# builtin. We actually do not need to explicitly initialize these arrays. +#declare -ga precmd_functions +#declare -ga preexec_functions + +# Trims leading and trailing whitespace from $2 and writes it to the variable +# name passed as $1 +__bp_trim_whitespace() { + local var=${1:?} text=${2:-} + text="${text#"${text%%[![:space:]]*}"}" # remove leading whitespace characters + text="${text%"${text##*[![:space:]]}"}" # remove trailing whitespace characters + printf -v "$var" '%s' "$text" +} + + +# Trims whitespace and removes any leading or trailing semicolons from $2 and +# writes the resulting string to the variable name passed as $1. This also +# removes the no-op colons, which are converted from the hooks to remove. Used +# for manipulating substrings in PROMPT_COMMAND +__bp_sanitize_string() { + local var=${1:?} sanitized=${2:-} + + local unset_extglob= + if ! shopt -q extglob; then + unset_extglob=yes + shopt -s extglob + fi + + # We specify newline character through the variable `nl' because $'\n' + # inside "${var//...}" is treated literally as "\$'\\n'" when `extquote' is + # unset (shopt -u extquote). (Note: Bash 5.2's extquote seems to be buggy.) + local tmp nl=$'\n' + while + # Note: Quoting parameter expansions $nl in PAT of ${var//PAT/REP} is + # required by shellcheck. On the other hand, we should not quote the + # parameter expansions $nl in REP because the quotes will remain in the + # replaced result with `shopt -s compat42'. + # Note: We use ?(+([[:blank:]])) instead of *([[:blank:]]) to work + # around a bug of Bash 3.2 that *(...) is not properly processed as + # extglob at the beginning of the pattern in ${var//pat/rep}. + tmp="${sanitized//?(+([[:blank:]]))[";$nl"]*([[:blank:]]):*([[:blank:]])[";$nl"]*([[:blank:]])/$nl}" + [[ "$tmp" != "$sanitized" ]] + do + sanitized="$tmp" + done + sanitized="${sanitized#:*([[:blank:]])[";$nl"]}" + sanitized="${sanitized%[";$nl"]*([[:blank:]]):}" + __bp_trim_whitespace sanitized "$sanitized" + sanitized=${sanitized%;} + sanitized=${sanitized#;} + __bp_trim_whitespace sanitized "$sanitized" + if [[ "$sanitized" == ":" ]]; then + sanitized= + fi + printf -v "$var" '%s' "$sanitized" + + if [[ -n "$unset_extglob" ]]; then + shopt -u extglob + fi +} + + +# Bash >= 5.1 supports the array version of PROMPT_COMMAND. +__bp_use_array_prompt_command() { + (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )) +} + + +# Remove $1 and sanitize each elements of PROMPT_COMMAND. We want to keep +# PROMPT_COMMAND scalar in bash < 5.1 because some configuration tests the +# support for the array PROMPT_COMMAND by checking the array attribute of +# PROMPT_COMMAND. +__bp_remove_command_from_prompt_command() { + local removed_command="${1-}" + if __bp_use_array_prompt_command; then + local i sanitized_prompt_command + for i in "${!PROMPT_COMMAND[@]}"; do + sanitized_prompt_command="${PROMPT_COMMAND[i]:-}" + sanitized_prompt_command="${sanitized_prompt_command//"$removed_command"/:}" + __bp_sanitize_string sanitized_prompt_command "$sanitized_prompt_command" + if [[ -n "$sanitized_prompt_command" ]]; then + PROMPT_COMMAND[i]="$sanitized_prompt_command" + else + unset -v 'PROMPT_COMMAND[i]' + fi + done + else + local sanitized_prompt_command="${PROMPT_COMMAND:-}" + sanitized_prompt_command="${sanitized_prompt_command//"$removed_command"/:}" # no-op + __bp_sanitize_string PROMPT_COMMAND "$sanitized_prompt_command" + fi +} + + +# This function is installed as part of the PROMPT_COMMAND; +# It sets a variable to indicate that the prompt was just displayed, +# to allow the DEBUG trap to know that the next command is likely interactive. +__bp_interactive_mode() { + if [[ "${1-}" != "force" && ! "${BATS_VERSION-}" ]] && (( ${#FUNCNAME[*]} > 1 )); then + # When this function is not called from the top level, the current + # function call is probably performed via PROMPT_COMMAND saved by + # another framework (e.g., starship). In this case, we do not want to + # turn on the "interactive mode" here. + return 0 + fi + + __bp_preexec_interactive_mode="on" +} + + +# This function is installed as part of the PROMPT_COMMAND. +# It will invoke any functions defined in the precmd_functions array. +__bp_precmd_invoke_cmd() { + # Save the returned value and the last argument from our last command, and + # the returned value from each process in its pipeline. Note: this MUST be + # the first thing done in this function. + # BP_PIPESTATUS may be unused, ignore + # shellcheck disable=SC2034 + __bp_last_ret_value="$?" __bp_last_argument_prev_command="$_" \ + BP_PIPESTATUS=("${PIPESTATUS[@]}") + + + # Don't invoke precmds if we are inside an execution of an "original + # prompt command" by another precmd execution loop. This avoids infinite + # recursion. + if (( __bp_inside_precmd > 0 )); then + return "$__bp_last_ret_value" + fi + + # Check and adjust PROMPT_COMMAND to make sure that PROMPT_COMMAND has the + # form "__bp_precmd_invoke_cmd; ...; __bp_interactive_mode" + if ! __bp_install_prompt_command; then + if [[ "${1-}" != "force" && ! "${BATS_VERSION-}" ]] && (( ${#FUNCNAME[*]} > 1 )); then + # When PROMPT_COMMAND is already properly set up but this function + # is not called from the top level, the current function call is + # probably performed via PROMPT_COMMAND saved by another framework + # (e.g., starship). In this case, we do not need to invoke precmd + # because it is supposed to be already processed by the top-level + # __bp_precmd_invoke_cmd. + return "$__bp_last_ret_value" + fi + fi + + local __bp_inside_precmd=1 + __bp_invoke_precmd_functions "$__bp_last_ret_value" "$__bp_last_argument_prev_command" + + __bp_set_ret_value "$__bp_last_ret_value" "$__bp_last_argument_prev_command" +} + +# This function invokes every function defined in the "precmd_functions" array. +# This function receives the arguments $1 and $2 for $? and $_, respectively, +# which will be set for each precmd function. This function returns the last +# non-zero exit status of the hook functions. If there is no error, this +# function returns 0. +__bp_invoke_precmd_functions() { + local lastexit=$1 lastarg=$2 + # Invoke every function defined in our function array. + local precmd_function + local precmd_function_ret_value + local precmd_ret_value=0 + for precmd_function in "${precmd_functions[@]}"; do + + # Only execute this function if it actually exists. + # Test existence of functions with: declare -[Ff] + if type -t "$precmd_function" 1>/dev/null; then + __bp_set_ret_value "$lastexit" "$lastarg" + # Quote our function invocation to prevent issues with IFS + "$precmd_function" + precmd_function_ret_value=$? + if [[ "$precmd_function_ret_value" != 0 ]]; then + precmd_ret_value="$precmd_function_ret_value" + fi + fi + done + + __bp_set_ret_value "$precmd_ret_value" +} + +# Sets a return value in $?. We may want to get access to the $? variable in our +# precmd functions. This is available for instance in zsh. We can simulate it in bash +# by setting the value here. +__bp_set_ret_value() { + return ${1:+"$1"} +} + +__bp_in_prompt_command() { + + local prompt_command_array IFS=$'\n;' + read -rd '' -a prompt_command_array <<< "${PROMPT_COMMAND[*]:-}" + + local trimmed_arg + __bp_trim_whitespace trimmed_arg "${1:-}" + + local command trimmed_command + for command in "${prompt_command_array[@]:-}"; do + __bp_trim_whitespace trimmed_command "$command" + if [[ "$trimmed_command" == "$trimmed_arg" ]]; then + return 0 + fi + done + + return 1 +} + +__bp_load_this_command_from_history() { + this_command=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1) + this_command="${this_command#*[[:digit:]][* ] }" + + # Sanity check to make sure we have something to invoke our function with. + [[ -n "$this_command" ]] +} + +# This function is installed as the DEBUG trap. It is invoked before each +# interactive prompt display. Its purpose is to inspect the current +# environment to attempt to detect if the current command is being invoked +# interactively, and invoke 'preexec' if so. +__bp_preexec_invoke_exec() { + local lastarg=$_ + + # Don't invoke preexecs if we are inside of another preexec. + if (( __bp_inside_preexec > 0 )); then + return + fi + local __bp_inside_preexec=1 + + # Checks if the file descriptor is not standard out (i.e. '1') + # __bp_delay_install checks if we're in test. Needed for bats to run. + # Prevents preexec from being invoked for functions in PS1 + if [[ ! -t 1 && -z "${__bp_delay_install:-}" ]]; then + return + fi + + if [[ -n "${COMP_POINT:-}" || -n "${READLINE_POINT:-}" ]]; then + # We're in the middle of a completer or a keybinding set up by "bind + # -x". This obviously can't be an interactively issued command. + return + fi + if [[ -z "${__bp_preexec_interactive_mode:-}" ]]; then + # We're doing something related to displaying the prompt. Let the + # prompt set the title instead of me. + return + else + # If we're in a subshell, then the prompt won't be re-displayed to put + # us back into interactive mode, so let's not set the variable back. + # In other words, if you have a subshell like + # (sleep 1; sleep 2) + # You want to see the 'sleep 2' as a set_command_title as well. + if [[ 0 -eq "${BASH_SUBSHELL:-}" ]]; then + __bp_preexec_interactive_mode="" + fi + fi + + if __bp_in_prompt_command "${BASH_COMMAND:-}"; then + # If we're executing something inside our prompt_command then we don't + # want to call preexec. Bash prior to 3.1 can't detect this at all :/ + __bp_preexec_interactive_mode="" + return + fi + + # Save the contents of $_ so that it can be restored later on. + # https://stackoverflow.com/questions/40944532/bash-preserve-in-a-debug-trap#40944702 + __bp_last_argument_prev_command=$lastarg + + local this_command + __bp_load_this_command_from_history || return + + __bp_invoke_preexec_functions "${__bp_last_ret_value:-}" "$__bp_last_argument_prev_command" "$this_command" + local preexec_ret_value=$? + + # Restore the last argument of the last executed command, and set the return + # value of the DEBUG trap to be the return code of the last preexec function + # to return an error. + # If `extdebug` is enabled a non-zero return value from any preexec function + # will cause the user's command not to execute. + # Run `shopt -s extdebug` to enable + __bp_set_ret_value "$preexec_ret_value" "$__bp_last_argument_prev_command" +} + +__bp_invoke_preexec_from_ps0() { + __bp_last_argument_prev_command="${1:-}" + + local this_command + __bp_load_this_command_from_history || return + + __bp_invoke_preexec_functions "${__bp_last_ret_value:-}" "$__bp_last_argument_prev_command" "$this_command" +} + +# This function invokes every function defined in the "preexec_functions" +# array. This function receives the arguments $1 and $2 for $? and $_, +# respectively, which will be set for each preexec function. The third +# argument $3 specifies the user command that is going to be executed +# (corresponding to BASH_COMMAND in the DEBUG trap). This function returns the +# last non-zero exit status from the preexec functions. If there is no error, +# this function returns `0`. +__bp_invoke_preexec_functions() { + local lastexit=$1 lastarg=$2 this_command=$3 + local preexec_function + local preexec_function_ret_value + local preexec_ret_value=0 + for preexec_function in "${preexec_functions[@]:-}"; do + + # Only execute each function if it actually exists. + # Test existence of function with: declare -[fF] + if type -t "$preexec_function" 1>/dev/null; then + __bp_set_ret_value "$lastexit" "$lastarg" + # Quote our function invocation to prevent issues with IFS + "$preexec_function" "$this_command" + preexec_function_ret_value="$?" + if [[ "$preexec_function_ret_value" != 0 ]]; then + preexec_ret_value="$preexec_function_ret_value" + fi + fi + done + __bp_set_ret_value "$preexec_ret_value" +} + +__bp_hook_preexec_into_debug() { + local trap_string + trap_string=$(trap -p DEBUG) + trap '__bp_preexec_invoke_exec "$_"' DEBUG + + # Preserve any prior DEBUG trap as a preexec function + eval "local trap_argv=(${trap_string:-})" + local prior_trap=${trap_argv[2]:-} + if [[ -n "$prior_trap" ]]; then + eval '__bp_original_debug_trap() { + '"$prior_trap"' + }' + preexec_functions+=(__bp_original_debug_trap) + fi + + # Adjust our HISTCONTROL Variable if needed. + __bp_adjust_histcontrol + + # Issue #25. Setting debug trap for subshells causes sessions to exit for + # backgrounded subshell commands (e.g. (pwd)& ). Believe this is a bug in Bash. + # + # Disabling this by default. It can be enabled by setting this variable. + if [[ -n "${__bp_enable_subshells:-}" ]]; then + + # Set so debug trap will work be invoked in subshells. + set -o functrace > /dev/null 2>&1 + shopt -s extdebug > /dev/null 2>&1 + fi +} + +__bp_hook_preexec_into_ps0() { + # shellcheck disable=SC2016 + PS0=${PS0-}'${ __bp_invoke_preexec_from_ps0 "$_" >&2; }' + + # Adjust our HISTCONTROL Variable if needed. + __bp_adjust_histcontrol +} + +if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then + __bp_hook_preexec_proc=__bp_hook_preexec_into_ps0 +else + __bp_hook_preexec_proc=__bp_hook_preexec_into_debug +fi + +__bp_install() { + local lastexit=$? lastarg=$_ + # Exit if we already have this installed. + # shellcheck disable=SC2016 + if [[ "${PROMPT_COMMAND[*]:-}" == *'__bp_precmd_invoke_cmd "$_"'* ]]; then + return 1 + fi + + "$__bp_hook_preexec_proc" + + # Remove setting our trap install string and sanitize the existing prompt command string + __bp_remove_command_from_prompt_command "$__bp_install_string" + + __bp_install_prompt_command || true + + # Add two functions to our arrays for convenience + # of definition. + precmd_functions+=(precmd) + preexec_functions+=(preexec) + + # Invoke our two functions manually that were added to $PROMPT_COMMAND + __bp_set_ret_value "$lastexit" "$lastarg" + __bp_precmd_invoke_cmd force + __bp_interactive_mode force +} + +# Note: We need to add the "trace" attribute to these functions so that "trap +# ... DEBUG" inside "__bp_install" and "__bp_hook_preexec_into_debug" takes +# effect even when there is an existing DEBUG trap. +declare -ft __bp_install __bp_hook_preexec_into_debug + +# Encloses PROMPT_COMMAND hooks within __bp_precmd_invoke_cmd and +# __bp_interactive_mode. If all the PROMPT_COMMAND hooks are already surrounded +# by __bp_precmd_invoke_cmd and __bp_interactive_mode, the function exits with +# status 1. +__bp_install_prompt_command() { + local prompt_command="${PROMPT_COMMAND:-}" + if __bp_use_array_prompt_command; then + local IFS=$'\n' + prompt_command="${PROMPT_COMMAND[*]:-}" + IFS=$' \t\n' + fi + + # Exit if we already have a properly set-up hooks in PROMPT_COMMAND + # shellcheck disable=SC2016 + local prologue='__bp_precmd_invoke_cmd "$_"' + local epilogue='__bp_interactive_mode' + if [[ "$prompt_command" == "$prologue"$'\n'* && "$prompt_command" == *$'\n'"$epilogue" ]]; then + return 1 + fi + + __bp_remove_command_from_prompt_command "$prologue" + __bp_remove_command_from_prompt_command "$epilogue" + + # Install our hooks in PROMPT_COMMAND to allow our trap to know when we've + # actually entered something. + # shellcheck disable=SC2128,SC2178 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND=$prologue${PROMPT_COMMAND:+$'\n'$PROMPT_COMMAND} + if __bp_use_array_prompt_command; then + PROMPT_COMMAND+=("$epilogue") + else + # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND+=$'\n'$epilogue + fi + return 0 +} + +# Sets an installation string as part of our PROMPT_COMMAND to install +# after our session has started. This allows bash-preexec to be included +# at any point in our bash profile. +__bp_install_after_session_init() { + # bash-preexec needs to modify these variables in order to work correctly + # if it can't, just stop the installation + __bp_require_not_readonly PROMPT_COMMAND HISTCONTROL HISTTIMEFORMAT || return + if [[ $__bp_hook_preexec_proc == '__bp_hook_preexec_into_ps0' ]]; then + __bp_require_not_readonly PS0 || return + fi + + if __bp_use_array_prompt_command; then + PROMPT_COMMAND+=("${__bp_install_string}") + else + local sanitized_prompt_command + __bp_sanitize_string sanitized_prompt_command "${PROMPT_COMMAND:-}" + if [[ -n "$sanitized_prompt_command" ]]; then + # shellcheck disable=SC2178 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND=${sanitized_prompt_command}$'\n' + fi + # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND+=${__bp_install_string} + fi +} + +# Run our install so long as we're not delaying it. +if [[ -z "${__bp_delay_install:-}" ]]; then + __bp_install_after_session_init +fi diff --git a/vendor/bash-preexec/test/README.md b/vendor/bash-preexec/test/README.md new file mode 100644 index 00000000..fd6613c0 --- /dev/null +++ b/vendor/bash-preexec/test/README.md @@ -0,0 +1,22 @@ +Testing `bash-preexec` +====================== + +**Note on test conditions** + +When writing test conditions, use `[ ... ]` instead of `[[ ... ]]` since the +former are supported by Bats on Bash versions before 4.1. In particular, macOS +uses Bash 3.2, and `[[ ... ]]` tests always pass on macOS. + +In some cases, you may want to use a feature unique to `[[ ... ]]` such as +pattern matching (`[[ $name = a* ]]`) or regular expressions (`[[ $(date) =~ +^Fri\ ...\ 13 ]]`). In those cases, use the following pattern to replace “bare” +`[[ ... ]]`. + +``` +[[ ... ]] || return 1 +``` + +References: +* [Differences between `[` and `[[`](http://mywiki.wooledge.org/BashFAQ/031) +* [Problems with `[[` in Bats](https://github.com/sstephenson/bats/issues/49) +* [Using `|| return 1` instead of `|| false`](https://github.com/bats-core/bats-core/commit/e5695a673faad4d4d33446ed5c99d70dbfa6d8be) diff --git a/vendor/bash-preexec/test/bash-preexec.bats b/vendor/bash-preexec/test/bash-preexec.bats new file mode 100644 index 00000000..14e52e70 --- /dev/null +++ b/vendor/bash-preexec/test/bash-preexec.bats @@ -0,0 +1,742 @@ +#!/usr/bin/env bats + +setup() { + PROMPT_COMMAND='' # in case the invoking shell has set this + history -s fake command # preexec requires there be some history + set -o nounset # in case the user has this set + __bp_delay_install="true" + source "${BATS_TEST_DIRNAME}/../bash-preexec.sh" +} + +# Evaluates all the elements of PROMPT_COMMAND +eval_PROMPT_COMMAND() { + local lastexit=$? lastarg=$_ prompt_command + for prompt_command in "${PROMPT_COMMAND[@]}"; do + __bp_set_ret_value "$lastexit" "$lastarg" + eval "$prompt_command" + done +} + +# Joins the elements of PROMPT_COMMAND with $'\n' +join_PROMPT_COMMAND() { + local IFS=$'\n' + echo "${PROMPT_COMMAND[*]}" +} + +bp_install() { + __bp_install_after_session_init + eval_PROMPT_COMMAND +} + +test_echo() { + echo "test echo" +} + +test_preexec_echo() { + printf "%s\n" "$1" +} + +# Helper functions necessary because Bats' run doesn't preserve $? +return_exit_code() { + return $1 +} + +set_exit_code_and_run_precmd() { + return_exit_code "${1:-0}" "${2-$_}" + __bp_precmd_invoke_cmd +} + + +@test "sourcing bash-preexec should exit with 1 if we're not using bash" { + unset BASH_VERSION + run source "${BATS_TEST_DIRNAME}/../bash-preexec.sh" + [ $status -eq 1 ] + [ -z "$output" ] +} + +@test "sourcing bash-preexec should exit with 1 if we're using an older version of bash" { + if type -p bash-3.0 &>/dev/null; then + run bash-3.0 -c "source \"${BATS_TEST_DIRNAME}/../bash-preexec.sh\"" + [ "$status" -eq 1 ] + [ -z "$output" ] + else + skip + fi +} + +@test "__bp_install should exit if it's already installed" { + bp_install + + run '__bp_install' + [ $status -eq 1 ] + [ -z "$output" ] +} + +@test "__bp_install should remove trap logic and itself from PROMPT_COMMAND" { + __bp_install_after_session_init + + # Assert that before running, the command contains the install string, and + # afterwards it does not + [[ "$(join_PROMPT_COMMAND)" == *"$__bp_install_string"* ]] || return 1 + + eval_PROMPT_COMMAND + + [[ "$(join_PROMPT_COMMAND)" != *"$__bp_install_string"* ]] || return 1 +} + +@test "__bp_install should remove trap logic and itself from modified PROMPT_COMMAND" { + PROMPT_COMMAND=() + __bp_install_after_session_init + PROMPT_COMMAND="$PROMPT_COMMAND; true" + + # Assert that before running, the command contains the install string, and + # afterwards it does not + [[ "$(join_PROMPT_COMMAND)" == *"$__bp_install_string"* ]] || return 1 + + eval_PROMPT_COMMAND + + [[ "$(join_PROMPT_COMMAND)" != *"$__bp_install_string"* ]] || return 1 +} + + +@test "__bp_install should preserve an existing DEBUG trap" { + trap_invoked_count=0 + foo() { (( trap_invoked_count += 1 )); } + + # note setting this causes BATS to mis-report the failure line when this test fails + trap foo DEBUG + original_debug_trap=$(trap -p DEBUG) + [ "$(cut -d' ' -f3 <<< "$original_debug_trap")" == "'foo'" ] + + bp_install + trap_count_snapshot=$trap_invoked_count + + if [[ $__bp_hook_preexec_proc == '__bp_hook_preexec_into_debug' ]]; then + # We override the DEBUG trap with the DEBUG approach to preexec + [ "$(trap -p DEBUG | cut -d' ' -f3)" == "'__bp_preexec_invoke_exec" ] + [[ "${preexec_functions[*]}" == *"__bp_original_debug_trap"* ]] || return 1 + [[ $(declare -f __bp_original_debug_trap) == *$'\n'" foo"$'\n'* ]] || return 1 + else + # We do not modify the DEBUG trap in the other approaches + [ "$(trap -p DEBUG)" == "$original_debug_trap" ] + fi + + __bp_interactive_mode # triggers the DEBUG trap + + # ensure the trap count is still being incremented after the trap's been overwritten + (( trap_count_snapshot < trap_invoked_count )) +} + +@test "__bp_install should preserve an existing DEBUG trap containing quotes" { + trap_invoked_count=0 + foo() { (( trap_invoked_count += 1 )); } + + # constants + single_quote="'" single_quote_escape="'\''" + original_trap_command="foo && echo 'hello' > /dev/null" + + # note setting this causes BATS to mis-report the failure line when this test fails + trap "$original_trap_command" debug + original_debug_trap=$(trap -p DEBUG) + [ "$original_debug_trap" == "trap -- '${original_trap_command//$single_quote/$single_quote_escape}' DEBUG" ] + + bp_install + trap_count_snapshot=$trap_invoked_count + + if [[ $__bp_hook_preexec_proc == '__bp_hook_preexec_into_debug' ]]; then + # We override the DEBUG trap with the DEBUG approach to preexec + [ "$(trap -p DEBUG | cut -d' ' -f3)" == "'__bp_preexec_invoke_exec" ] + [[ "${preexec_functions[*]}" == *"__bp_original_debug_trap"* ]] || return 1 + [[ $(declare -f __bp_original_debug_trap) == *$'\n'" $original_trap_command"$'\n'* ]] || return 1 + else + # We do not modify the DEBUG trap in the other approaches + [ "$(trap -p DEBUG)" == "$original_debug_trap" ] + fi + + __bp_interactive_mode # triggers the DEBUG trap + + # ensure the trap count is still being incremented after the trap's been overwritten + (( trap_count_snapshot < trap_invoked_count )) +} + +@test "__bp_install should preserve an existing PS0" { + original_PS0=${PS0-} + + bp_install + + if [[ $__bp_hook_preexec_proc == '__bp_hook_preexec_into_ps0' ]]; then + # We modify PS0 with the PS0 approach to preexec, but the original contents + # of PS0 should be preserved. + [ "${PS0-}" != "$original_PS0" ] + [[ ${PS0-} == *"$original_PS0"* ]] || return 1 + else + # We do not modify PS0 in the other approaches + [ "${PS0-}" == "$original_PS0" ] + fi +} + +@test "__bp_install_prompt_command should adjust modified PROMPT_COMMAND" { + unset -v PROMPT_COMMAND + PROMPT_COMMAND="echo PREHOOK" + + # First install + __bp_install_prompt_command + expected_result=$'__bp_precmd_invoke_cmd "$_"\necho PREHOOK\n__bp_interactive_mode' + [ "$(join_PROMPT_COMMAND)" == "$expected_result" ] + + # User modification + if __bp_use_array_prompt_command; then + PROMPT_COMMAND+=('echo POSTHOOK') + else + PROMPT_COMMAND+=$'\necho POSTHOOK' + fi + expected_result=$'__bp_precmd_invoke_cmd "$_"\necho PREHOOK\n__bp_interactive_mode\necho POSTHOOK' + [ "$(join_PROMPT_COMMAND)" == "$expected_result" ] + + # Re-adjust + __bp_install_prompt_command + expected_result=$'__bp_precmd_invoke_cmd "$_"\necho PREHOOK\necho POSTHOOK\n__bp_interactive_mode' + [ "$(join_PROMPT_COMMAND)" == "$expected_result" ] +} + +@test "__bp_install_prompt_command should be skipped when already set up" { + unset -v PROMPT_COMMAND + PROMPT_COMMAND="" + + # First install should succeed + __bp_install_prompt_command || return 1 + + # Second install should skip processing and return 1 + ! __bp_install_prompt_command || return 1 +} + +@test "__bp_sanitize_string should remove semicolons and trim space" { + + __bp_sanitize_string output " true1; "$'\n' + [ "$output" == "true1" ] + + __bp_sanitize_string output " ; true2; " + [ "$output" == "true2" ] + + __bp_sanitize_string output $'\n'" ; true3; " + [ "$output" == "true3" ] + +} + +@test "__bp_sanitize_string should remove no-op colons" { + __bp_sanitize_string output ':' + [ "$output" == "" ] + + __bp_sanitize_string output $':\n:' + [ "$output" == "" ] + + __bp_sanitize_string output $':\n:;echo USER1' + [ "$output" == "echo USER1" ] + + __bp_sanitize_string output $'echo USER2\n:\necho USER3' + expected_result=$'echo USER2\necho USER3' + [ "$output" == "$expected_result" ] + + __bp_sanitize_string output $'echo USER4;:;echo USER5' + expected_result=$'echo USER4\necho USER5' + [ "$output" == "$expected_result" ] + + __bp_sanitize_string output $'echo USER6;:\necho USER7' + expected_result=$'echo USER6\necho USER7' + [ "$output" == "$expected_result" ] + + __bp_sanitize_string output $':\n: ; echo USER8' + [ "$output" == "echo USER8" ] + + __bp_sanitize_string output $':\n: ; echo USER9' + [ "$output" == "echo USER9" ] + + __bp_sanitize_string output $'echo USER10 ; :\n: ; echo USER11' + expected_result=$'echo USER10\necho USER11' + [ "$output" == "$expected_result" ] +} + +@test "Appending to PROMPT_COMMAND should work after bp_install" { + bp_install + + PROMPT_COMMAND="$PROMPT_COMMAND; true" + eval_PROMPT_COMMAND +} + +@test "Appending or prepending to PROMPT_COMMAND should work after bp_install_after_session_init" { + __bp_install_after_session_init + nl=$'\n' + # On Bash 5.1+ we append to the array properly, + # so first element of PROMPT_COMMAND may still be empty. + PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }true" + PROMPT_COMMAND="$PROMPT_COMMAND $nl true" + PROMPT_COMMAND="$PROMPT_COMMAND; true" + PROMPT_COMMAND="true; $PROMPT_COMMAND" + PROMPT_COMMAND="true; $PROMPT_COMMAND" + PROMPT_COMMAND="true; $PROMPT_COMMAND" + PROMPT_COMMAND="true $nl $PROMPT_COMMAND" + eval_PROMPT_COMMAND +} + +@test "Appending or prepending to PROMPT_COMMAND array should work after bp_install_after_session_init" { + if __bp_use_array_prompt_command; then + PROMPT_COMMAND=() + __bp_install_after_session_init + nl=$'\n' + PROMPT_COMMAND=("${PROMPT_COMMAND[@]}" "true") + PROMPT_COMMAND=("${PROMPT_COMMAND[@]}" "$nl true") + PROMPT_COMMAND=("${PROMPT_COMMAND[@]}" "true") + PROMPT_COMMAND=("true" "${PROMPT_COMMAND[@]}") + PROMPT_COMMAND=("true" "${PROMPT_COMMAND[@]}") + PROMPT_COMMAND=("true" "${PROMPT_COMMAND[@]}") + PROMPT_COMMAND=("true $nl" "${PROMPT_COMMAND[@]}") + eval_PROMPT_COMMAND + else + skip + fi +} + +# Case where a user is appending or prepending to PROMPT_COMMAND. +# This can happen after 'source bash-preexec.sh' e.g. +# source bash-preexec.sh; PROMPT_COMMAND="$PROMPT_COMMAND; other_prompt_command_hook" +@test "Adding to PROMPT_COMMAND before and after initiating install" { + PROMPT_COMMAND="echo before" + PROMPT_COMMAND="$PROMPT_COMMAND; echo before2" + __bp_install_after_session_init + PROMPT_COMMAND="$PROMPT_COMMAND"$'\necho after' + PROMPT_COMMAND="echo after2; $PROMPT_COMMAND;" + + eval_PROMPT_COMMAND + + expected_result=$'__bp_precmd_invoke_cmd "$_"\necho after2; echo before; echo before2\necho after\n__bp_interactive_mode' + [ "$(join_PROMPT_COMMAND)" == "$expected_result" ] +} + +@test "Adding to PROMPT_COMMAND after with semicolon" { + PROMPT_COMMAND="echo before" + __bp_install_after_session_init + if __bp_use_array_prompt_command; then + PROMPT_COMMAND[${#PROMPT_COMMAND[@]}-1]+='; echo after' + else + PROMPT_COMMAND+='; echo after' + fi + + eval_PROMPT_COMMAND + + expected_result=$'__bp_precmd_invoke_cmd "$_"\necho before\necho after\n__bp_interactive_mode' + [ "$(join_PROMPT_COMMAND)" == "$expected_result" ] +} + +@test "during install PROMPT_COMMAND and precmd functions should be executed each once" { + PROMPT_COMMAND="echo before" + PROMPT_COMMAND="$PROMPT_COMMAND; echo before2" + __bp_install_after_session_init + PROMPT_COMMAND="$PROMPT_COMMAND; echo after" + PROMPT_COMMAND="echo after2; $PROMPT_COMMAND;" + + precmd() { echo "inside precmd"; } + run eval_PROMPT_COMMAND + [ "${#lines[@]}" == '5' ] + [ "${lines[0]}" == "after2" ] + [ "${lines[1]}" == "before" ] + [ "${lines[2]}" == "before2" ] + if __bp_use_array_prompt_command; then + [ "${lines[3]}" == "after" ] + [ "${lines[4]}" == "inside precmd" ] + else + [ "${lines[3]}" == "inside precmd" ] + [ "${lines[4]}" == "after" ] + fi +} + +@test "during install PROMPT_COMMAND and precmd functions should be executed each once (Bash 5.1+ PROMPT_COMMAND array)" { + if __bp_use_array_prompt_command; then + PROMPT_COMMAND=("echo before") + PROMPT_COMMAND=("${PROMPT_COMMAND[@]}" "echo before2") + __bp_install_after_session_init + PROMPT_COMMAND=("${PROMPT_COMMAND[@]}" "echo after") + PROMPT_COMMAND=("echo after2" "${PROMPT_COMMAND[@]}") + + precmd() { echo "inside precmd"; } + run eval_PROMPT_COMMAND + [ "${#lines[@]}" == '5' ] + [ "${lines[0]}" == "after2" ] + [ "${lines[1]}" == "before" ] + [ "${lines[2]}" == "before2" ] + [ "${lines[3]}" == "inside precmd" ] + [ "${lines[4]}" == "after" ] + else + skip + fi +} + +@test "No functions defined for preexec should simply return" { + __bp_interactive_mode + + run '__bp_preexec_invoke_exec' 'true' + [ $status -eq 0 ] + [ -z "$output" ] +} + +@test "precmd should execute a function once" { + precmd_functions+=(test_echo) + run set_exit_code_and_run_precmd + [ $status -eq 0 ] + [ "$output" == "test echo" ] +} + +@test "precmd should set \$? to be the previous exit code" { + echo_exit_code() { + echo "$?" + } + + precmd_functions+=(echo_exit_code) + run set_exit_code_and_run_precmd 251 + [ $status -eq 251 ] + [ "$output" == "251" ] +} + +@test "__bp_precmd_invoke_cmd should preserve \$? when taking the reentry-guard early return" { + # When (( __bp_inside_precmd > 0 )) is true, __bp_precmd_invoke_cmd returns + # early without reaching the trailing __bp_set_ret_value call. Without the + # explicit set_ret_value on this path, the bare `return` propagates the + # exit status of the (( ... )) test (always 0 when the condition is true), + # clobbering the user's actual last exit status. + run_with_reentry_guard() { + local __bp_inside_precmd=1 + return_exit_code 7 + __bp_precmd_invoke_cmd + } + run run_with_reentry_guard + [ $status -eq 7 ] +} + +@test "__bp_precmd_invoke_cmd should preserve \$? when taking the nested-call-frame early return" { + # Once PROMPT_COMMAND already contains our hooks, + # __bp_install_prompt_command returns 1, and if __bp_precmd_invoke_cmd is + # invoked from inside another function (FUNCNAME depth > 1), the function + # takes the "another framework wrapped our PROMPT_COMMAND" early + # return. Without the explicit set_ret_value on this path the function used + # to `return 0` and clobbered the caller's last exit status. + # + # The path's own guard short-circuits when BATS_VERSION is set so bats's + # own scaffolding doesn't trip it; we clear BATS_VERSION inside the nested + # call to actually exercise the path. + bp_install + run_nested() { + BATS_VERSION="" return_exit_code 7 + BATS_VERSION="" __bp_precmd_invoke_cmd + } + run run_nested + [ $status -eq 7 ] +} + +@test "__bp_precmd_invoke_cmd installed in PROMPT_COMMAND should preserve \$? and \$_" { + unset -v PROMPT_COMMAND + PROMPT_COMMAND='save_lastexit=$? save_lastarg=$_' + + __bp_install_prompt_command + precmd_functions=(precmd) + + # Note: The DEBUG and ERR traps set by Bats overwrite $_, so we cannot + # properly test the values of $_. We modify the DEBUG trap of Bats so that + # it properly preserves the value of $_. When we are not sure that the + # current DEBUG trap preserves $_, we skip the test. See + # https://github.com/bats-core/bats-core/pull/1208 for details. + if trap -p DEBUG | grep -qF \''bats_debug_trap "$BASH_SOURCE"'\'; then + bats_debug_trap_modified=1 + trap -- 'bats_debug_trap "$BASH_SOURCE" "$_"' DEBUG + fi + if ! trap -p DEBUG | grep -qF ' "$_"'\'; then + # If Bats' DEBUG trap does not preserve $_, and if it is not + # successfully updated to include "$_", we skip the test. + skip + elif [[ ${bats_debug_trap_modified-} && BASH_VERSINFO[0] -le 3 ]]; then + # When Bats' DEBUG trap does not care about $_, even if we modify the + # DEBUG trap, an issue still seems to remain in Bash 3.2. Therefore, + # we skip the test in Bash 3.2 when Bat's DEBUG trap is modified. + skip + else + run_prompt_command() { + __bp_set_ret_value '77' 'lastarg!bzBtcQDLoM' + eval_PROMPT_COMMAND + } + run_prompt_command || true + [ "$save_lastexit" == '77' ] + [ "$save_lastarg" == 'lastarg!bzBtcQDLoM' ] + fi +} + +@test "precmd should set \$BP_PIPESTATUS to the previous \$PIPESTATUS" { + echo_pipestatus() { + echo "${BP_PIPESTATUS[*]}" + } + # Helper function is necessary because Bats' run doesn't preserve $PIPESTATUS + set_pipestatus_and_run_precmd() { + false | true + __bp_precmd_invoke_cmd + } + + precmd_functions+=(echo_pipestatus) + run 'set_pipestatus_and_run_precmd' + [ $status -eq 0 ] + [ "$output" == "1 0" ] +} + +@test "precmd should set \$_ to be the previous last arg" { + echo_last_arg() { + echo "$_" + } + precmd_functions+=(echo_last_arg) + + bats_trap=$(trap -p DEBUG) + trap DEBUG # remove the Bats stack-trace trap so $_ doesn't get overwritten + : "last-arg" + __bp_preexec_interactive_mode=1 __bp_preexec_invoke_exec "$_" + eval "$bats_trap" # Restore trap + run set_exit_code_and_run_precmd 0 "$__bp_last_argument_prev_command" + [ $status -eq 0 ] + [ "$output" == "last-arg" ] +} + +@test "preexec should execute a function with the last command in our history" { + preexec_functions+=(test_preexec_echo) + __bp_interactive_mode + git_command="git commit -a -m 'committing some stuff'" + history -s $git_command + + run '__bp_preexec_invoke_exec' + [ $status -eq 0 ] + [ "$output" == "$git_command" ] +} + +@test "preexec should execute multiple functions in the order added to their arrays" { + fun_1() { echo "$1 one"; } + fun_2() { echo "$1 two"; } + preexec_functions+=(fun_1) + preexec_functions+=(fun_2) + __bp_interactive_mode + + run '__bp_preexec_invoke_exec' + [ $status -eq 0 ] + [ "${#lines[@]}" == '2' ] + [ "${lines[0]}" == "fake command one" ] + [ "${lines[1]}" == "fake command two" ] +} + +@test "preexec_functions before initialization should be preserved" { + fun_1() { reply_1="$1 one"; } + fun_2() { reply_2="$1 two"; } + + # preexec registered before initialization + preexec_functions+=(fun_1) + + # Initialization + __bp_preexec_interactive_mode="" bp_install + + # preexec registered after initialization + preexec_functions+=(fun_2) + + reply_1="" + reply_2="" + __bp_invoke_preexec_from_ps0 || return 1 + [ "$reply_1" == "fake command one" ] + [ "$reply_2" == "fake command two" ] +} + +@test "precmd should execute multiple functions in the order added to their arrays" { + fun_1() { echo "one"; } + fun_2() { echo "two"; } + precmd_functions+=(fun_1) + precmd_functions+=(fun_2) + + run set_exit_code_and_run_precmd + [ $status -eq 0 ] + [ "${#lines[@]}" == '2' ] + [ "${lines[0]}" == "one" ] + [ "${lines[1]}" == "two" ] +} + +@test "preexec should execute a function with IFS defined to local scope" { + IFS=_ + name_with_underscores_1() { parts=(1_2); echo $parts; } + preexec_functions+=(name_with_underscores_1) + + __bp_interactive_mode + run '__bp_preexec_invoke_exec' + [ $status -eq 0 ] + [ "$output" == "1 2" ] +} + +@test "precmd should execute a function with IFS defined to local scope" { + IFS=_ + name_with_underscores_2() { parts=(2_2); echo $parts; } + precmd_functions+=(name_with_underscores_2) + run set_exit_code_and_run_precmd + [ $status -eq 0 ] + [ "$output" == "2 2" ] +} + +@test "preexec should set \$? to be the exit code of preexec_functions" { + return_nonzero() { + return 1 + } + preexec_functions+=(return_nonzero) + + __bp_interactive_mode + + run '__bp_preexec_invoke_exec' + [ $status -eq 1 ] +} + +@test "__bp_invoke_precmd_functions should be transparent for \$? and \$_" { + tester1() { test1_lastexit=$? test1_lastarg=$_; } + tester2() { test2_lastexit=$? test2_lastarg=$_; } + precmd_functions=(tester1 tester2) + trap - DEBUG # remove the Bats stack-trace trap so $_ doesn't get overwritten + __bp_invoke_precmd_functions 111 'vxxJlwNx9VPJDA' || true + + [ "$test1_lastexit" == 111 ] + [ "$test1_lastarg" == 'vxxJlwNx9VPJDA' ] + [ "$test2_lastexit" == 111 ] + [ "$test2_lastarg" == 'vxxJlwNx9VPJDA' ] +} + +@test "__bp_invoke_precmd_functions returns the last non-zero exit status" { + tester1() { return 91; } + tester2() { return 38; } + tester3() { return 0; } + precmd_functions=(tester1 tester2 tester3) + status=0 + __bp_invoke_precmd_functions 1 'lastarg' || status=$? + + [ "$status" == 38 ] + + precmd_functions=(tester3) + status=0 + __bp_invoke_precmd_functions 1 'lastarg' || status=$? + + [ "$status" == 0 ] +} + +@test "__bp_invoke_preexec_functions should be transparent for \$? and \$_" { + tester1() { test1_lastexit=$? test1_lastarg=$_; } + tester2() { test2_lastexit=$? test2_lastarg=$_; } + preexec_functions=(tester1 tester2) + trap - DEBUG # remove the Bats stack-trace trap so $_ doesn't get overwritten + __bp_invoke_preexec_functions 87 'ehQrzHTHtE2E7Q' 'command' || true + + [ "$test1_lastexit" == 87 ] + [ "$test1_lastarg" == 'ehQrzHTHtE2E7Q' ] + [ "$test2_lastexit" == 87 ] + [ "$test2_lastarg" == 'ehQrzHTHtE2E7Q' ] +} + +@test "__bp_invoke_preexec_functions returns the last non-zero exit status" { + tester1() { return 52; } + tester2() { return 112; } + tester3() { return 0; } + preexec_functions=(tester1 tester2 tester3) + status=0 + __bp_invoke_preexec_functions 1 'lastarg' 'command' || status=$? + + [ "$status" == 112 ] + + preexec_functions=(tester3) + status=0 + __bp_invoke_preexec_functions 1 'lastarg' 'command' || status=$? + + [ "$status" == 0 ] +} + +@test "__bp_invoke_preexec_functions should supply a current command in the first argument" { + tester1() { test1_bash_command=$1; } + tester2() { test2_bash_command=$1; } + preexec_functions=(tester1 tester2) + __bp_invoke_preexec_functions 1 'lastarg' 'UEVkErELArSwjA' || true + + [ "$test1_bash_command" == 'UEVkErELArSwjA' ] + [ "$test2_bash_command" == 'UEVkErELArSwjA' ] +} + +@test "in_prompt_command should detect if a command is part of PROMPT_COMMAND" { + + PROMPT_COMMAND=$'precmd_invoke_cmd\n something; echo yo\n __bp_interactive_mode' + run '__bp_in_prompt_command' "something" + [ $status -eq 0 ] + + run '__bp_in_prompt_command' "something_else" + [ $status -eq 1 ] + + # Should trim commands and arguments here. + PROMPT_COMMAND=" precmd_invoke_cmd ; something ; some_stuff_here;" + run '__bp_in_prompt_command' " precmd_invoke_cmd " + [ $status -eq 0 ] + + PROMPT_COMMAND=" precmd_invoke_cmd ; something ; some_stuff_here;" + run '__bp_in_prompt_command' " not_found" + [ $status -eq 1 ] + +} + +@test "__bp_adjust_histcontrol should remove ignorespace and ignoreboth" { + + # Should remove ignorespace + HISTCONTROL="ignorespace:ignoredups:*" + __bp_adjust_histcontrol + [ "$HISTCONTROL" == ":ignoredups:*" ] + + # Should remove ignoreboth and replace it with ignoredups + HISTCONTROL="ignoreboth" + __bp_adjust_histcontrol + [ "$HISTCONTROL" == "ignoredups:" ] + + # Handle a few inputs + HISTCONTROL="ignoreboth:ignorespace:some_thing_else" + __bp_adjust_histcontrol + echo "$HISTCONTROL" + [ "$HISTCONTROL" == "ignoredups:::some_thing_else" ] + +} + +@test "preexec should respect HISTTIMEFORMAT" { + preexec_functions+=(test_preexec_echo) + __bp_interactive_mode + git_command="git commit -a -m 'committing some stuff'" + HISTTIMEFORMAT='%F %T ' + history -s $git_command + + run '__bp_preexec_invoke_exec' + [ $status -eq 0 ] + [ "$output" == "$git_command" ] +} + +@test "preexec should not strip whitespace from commands" { + preexec_functions+=(test_preexec_echo) + __bp_interactive_mode + history -s " this command has whitespace " + + run '__bp_preexec_invoke_exec' + [ $status -eq 0 ] + [ "$output" == " this command has whitespace " ] +} + +@test "preexec should preserve multi-line strings in commands" { + preexec_functions+=(test_preexec_echo) + __bp_interactive_mode + history -s "this 'command contains +a multiline string'" + run '__bp_preexec_invoke_exec' + [ $status -eq 0 ] + [ "$output" == "this 'command contains +a multiline string'" ] +} + +@test "preexec should work on options to 'echo' commands" { + preexec_functions+=(test_preexec_echo) + __bp_interactive_mode + history -s -- '-n' + run '__bp_preexec_invoke_exec' + [ $status -eq 0 ] + [ "$output" == '-n' ] +} diff --git a/vendor/bash-preexec/test/include-test.bats b/vendor/bash-preexec/test/include-test.bats new file mode 100644 index 00000000..36a5f256 --- /dev/null +++ b/vendor/bash-preexec/test/include-test.bats @@ -0,0 +1,26 @@ +#!/usr/bin/env bats + +@test "should not import if it's already defined" { + bash_preexec_imported="defined" + source "${BATS_TEST_DIRNAME}/../bash-preexec.sh" + [ -z $(type -t __bp_install) ] +} + +@test "should not import if it's already defined (old guard, don't use elsewhere!)" { + __bp_imported="defined" + source "${BATS_TEST_DIRNAME}/../bash-preexec.sh" + [ -z $(type -t __bp_install) ] +} + +@test "should import if not defined" { + unset bash_preexec_imported + source "${BATS_TEST_DIRNAME}/../bash-preexec.sh" + [ -n $(type -t __bp_install) ] +} + +@test "bp should stop installation if HISTTIMEFORMAT is readonly" { + readonly HISTTIMEFORMAT + run source "${BATS_TEST_DIRNAME}/../bash-preexec.sh" + [ $status -ne 0 ] + [[ "$output" =~ "HISTTIMEFORMAT" ]] || return 1 +} diff --git a/vendor/vendor.sh b/vendor/vendor.sh new file mode 100755 index 00000000..296802db --- /dev/null +++ b/vendor/vendor.sh @@ -0,0 +1,187 @@ +#!/bin/sh +# This script manages vendored repositories. See `./vendor.sh --help`. +# shellcheck disable=SC3043 +set -euf + +root_dir=$(command git rev-parse --show-toplevel) +vendor_dir=vendor +script_name=$(basename "$0") + +usage() { + cat << EOF +Usage: + $script_name add [name] + $script_name update + $script_name list + + specifies which branch/tag/commit to check out in the vendored +repository. + + is the name of the subdirectory in 'vendor/'. By default, it is +inferred from the repository URL. +EOF +} + +# args: +usage_error() { + printf >&2 '%s\n' "$script_name: $1" + printf >&2 '%s\n' "See 'vendor.sh --help' for usage information." + exit 1 +} + +# args: +error() { + printf >&2 '%s\n' "$script_name: $1" + exit 1 +} + +git() { + command git -C "$root_dir" "$@" +} + +# args: +fetch_and_resolve() { + git fetch -- "$1" "$2" + if ! git rev-parse --verify --quiet 'FETCH_HEAD^{commit}'; then + error "could not resolve '$2' in '$1'" + fi +} + +# args: +make_commit_msg() { + local verb="$1" + local name="$2" + local ref="$3" + local commit="$4" + local url="$5" + cat << EOF +vendor.sh: $verb $name at $ref + +vendored-repo-name: $name +vendored-repo-commit: $commit +vendored-repo-url: $url +EOF +} + +# For each vendored repository, print ``, ``, and `` +# separated by tabs. +get_repos() { + set -- --topo-order --all-match --no-show-signature \ + --grep='^vendor\.sh: ' \ + --grep='^vendored-repo-name: ' \ + --grep='^vendored-repo-commit: ' \ + --grep='^vendored-repo-url: ' \ + --format='@start%n%B%n@end' + git log "$@" | awk ' + $1 == "@start" { + name = "" + commit = "" + url = "" + next + } + $1 == "vendored-repo-name:" { + name = $2 + next + } + $1 == "vendored-repo-commit:" { + commit = $2 + next + } + $1 == "vendored-repo-url:" { + url = $2 + next + } + $1 == "@end" && name != "" && commit != "" && url != "" { + if (name in seen) next + seen[name] = 1 + print name "\t" url "\t" commit + } + ' +} + +cmd_add() { + if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + usage_error "unexpected number of arguments to 'add'" + fi + + local url="$1" + local ref="$2" + + local name + if [ $# -eq 3 ]; then + name="$3" + else + name=${url%/} + name=${name##*/} + name=${name%.git} + fi + + if [ -z "$name" ]; then + error "could not infer a name from '$url'" + fi + case $name in + */* | . | ..) error "invalid name: '$name'" ;; + esac + if [ -e "$vendor_dir/$name" ]; then + error "'$vendor_dir/$name' already exists" + fi + + local commit + commit=$(fetch_and_resolve "$url" "$ref") + git subtree add \ + --squash \ + --prefix="$vendor_dir/$name" \ + --message="$(make_commit_msg add "$name" "$ref" "$commit" "$url")" \ + "$commit" +} + +cmd_update() { + if [ "$#" -ne 2 ]; then + usage_error "unexpected number of arguments to 'update'" + fi + + local name="$1" + local ref="$2" + local url + url=$(get_repos | awk -F'\t' -v n="$name" '$1 == n { print $2; exit }') + if [ -z "$url" ]; then + error "no vendored repository named '$name'" + fi + + local commit + commit=$(fetch_and_resolve "$url" "$ref") + git subtree merge \ + --squash \ + --prefix="$vendor_dir/$name" \ + --message="$(make_commit_msg update "$name" "$ref" "$commit" "$url")" \ + "$commit" +} + +cmd_list() { + if [ "$#" -ne 0 ]; then + usage_error "unexpected number of arguments to 'list'" + fi + get_repos | awk '{ + print "name: " $1 + print "url: " $2 + print "commit: " $3 + }' +} + +if [ "$#" -lt 1 ]; then + usage_error "missing subcommand" +fi +cmd=$1 +shift + +case "$cmd" in + add|list|update) ;; + -h|--help) + usage + exit 0 + ;; + *) + usage_error "unknown command" + ;; +esac +"cmd_$cmd" "$@"