├── .gitignore ├── Rakefile ├── reiki.completion.bash ├── fish └── functions │ └── r.fish ├── dotrrc.example ├── LICENSE ├── README.md ├── reiki.plugin.bash └── r.bash /.gitignore: -------------------------------------------------------------------------------- 1 | *.taskpaper 2 | *.bak 3 | temp* 4 | .DS_Store 5 | *~ 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | task :build do |t, args| 2 | puts "Build: #{args.to_a}" 3 | end 4 | 5 | task :launch, :arg do |t, args| 6 | puts "Launch: #{args[:arg]}" 7 | end 8 | 9 | task :draft, :msg do |t, args| 10 | puts "Draft: #{args[:msg]}" 11 | end 12 | -------------------------------------------------------------------------------- /reiki.completion.bash: -------------------------------------------------------------------------------- 1 | # Completion function for `r` (http://brettterpstra.com/projects/reiki) 2 | # Still experimental 3 | 4 | __r_bash_complete() { 5 | toplevel=$PWD 6 | 7 | if [ ! -f Rakefile ]; then 8 | toplevel=$(git rev-parse --show-toplevel 2> /dev/null) 9 | if [ -z $toplevel || ! -f $toplevel/Rakefile ]; then 10 | return 1 11 | fi 12 | fi 13 | if [ -f $toplevel/Rakefile ]; then 14 | recent=`ls -t $toplevel/.r_completions~ $toplevel/Rakefile $toplevel/**/*.rake 2> /dev/null | head -n 1` 15 | if [[ $recent != '.r_completions~' ]]; then 16 | ruby -rrake -e "Rake::load_rakefile('$toplevel/Rakefile'); puts Rake::Task.tasks" > .r_completions~ 17 | fi 18 | 19 | COMPREPLY=($(compgen -W "`sort -u .r_completions~`" -- ${COMP_WORDS[COMP_CWORD]})) 20 | return 0 21 | fi 22 | } 23 | 24 | complete -o default -F __r_bash_complete r 25 | -------------------------------------------------------------------------------- /fish/functions/r.fish: -------------------------------------------------------------------------------- 1 | # Reiki wrapper function for Fish shell 2 | # Copy this file to ~/.config/fish/functions/r.fish 3 | # Or add to your config.fish 4 | function r --description 'Run Reiki (rake task shortcut with fuzzy matching)' 5 | # Update this path to point to your r.bash location 6 | # Common locations: ~/bin/r.bash, ~/scripts/r.bash, /usr/local/bin/r.bash 7 | set -l reiki_path (which r.bash 2>/dev/null) 8 | 9 | if test -z "$reiki_path" 10 | # Fallback: try to find r.bash in common locations 11 | for location in ~/bin/r.bash ~/scripts/r.bash /usr/local/bin/r.bash 12 | if test -f $location 13 | set reiki_path $location 14 | break 15 | end 16 | end 17 | end 18 | 19 | if test -z "$reiki_path" 20 | echo "Error: r.bash not found. Please update the path in this function." >&2 21 | return 1 22 | end 23 | 24 | bash $reiki_path $argv 25 | end 26 | -------------------------------------------------------------------------------- /dotrrc.example: -------------------------------------------------------------------------------- 1 | # Reiki Configuration File 2 | # Place this file at ~/.rrc to customize default behavior 3 | # 4 | # All settings can also be overridden by environment variables: 5 | # R_VERIFY_TASK, R_AUTO_TIMEOUT, R_DEBUG, R_QUIET, R_BUNDLE 6 | 7 | # Always verify task matches before running (true/false) 8 | # Default: false 9 | # verify_task=0 10 | 11 | # Timeout in seconds for auto-selection prompts 12 | # Set to 0 to disable auto-selection 13 | # Default: 5 14 | # auto_timeout=5 15 | 16 | # Run quietly - always use first match without prompting 17 | # Default: false 18 | # quiet=0 19 | 20 | # Enable debug output 21 | # Default: false 22 | # debug=0 23 | 24 | # Always prepend "bundle exec" to rake commands 25 | # Default: false (empty string) 26 | # bundle="" 27 | 28 | # Example configurations: 29 | 30 | # For interactive mode with longer timeout: 31 | # verify_task=1 32 | # auto_timeout=10 33 | 34 | # For automated/scripting use: 35 | # quiet=1 36 | # verify_task=0 37 | 38 | # For Ruby projects using Bundler: 39 | # bundle="bundle exec " 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Brett Terpstra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reiki: Fuzzy Rake Task Runner 2 | 3 | Reiki is a shell function (called with `r`) that lets you run Rake tasks with fuzzy matching, simple arguments, and serial task support. No brackets, just spaces, commas, and colons to separate serial tasks. 4 | 5 | --- 6 | 7 | ## Why? 8 | 9 | I get a little crazy with my [Rakefiles](http://www.ruby-doc.org/core-1.9.3/doc/rake/rakefile_rdoc.html) in cases where it's my command central, such as my Jekyll blog or my app [Marked 2](http://marked2app.com) where I need to perform a wide variety of tasks with various arguments and sequences. Typing out task names with arguments in Rake's command line syntax can be tedious with square brackets, commas, quoted arguments, etc. I build Reiki for my own sanity. 10 | 11 | I get that most people don't abuse Rakefiles to the extent that I do. You probably don't need this if you've never made a TextExpander shortcut to fill in tedious task names. 12 | 13 | ## Usage 14 | 15 | Reiki is called with `r` followed by the task or fragment of a task (fuzzy matched), followed by task arguments, commas separated, and optionally more tasks, separated by colons. 16 | 17 | If a `build` task were the only task in your Rakefile starting with "b": 18 | 19 | r b 20 | => rake build 21 | 22 | Reiki uses fuzzy matching to figure out which task you want to run. 23 | 24 | - If the first argument to `r` fully matches a rake task and there are no other potential matches, it runs it with any following arguments as arguments for that task (e.g. `r build true` becomes `rake build[true]`). 25 | - If there is more than one possible match, it checks to see if the second argument makes sense as `$1_$2`. If that's the case, it runs that with additional arguments. 26 | - If only the first letter(s) of a `$1_$2` task are provided, it guesses and checks before running (e.g. `r f d jekyll blogging` becomes `rake find_draft["jekyll blogging"]`). 27 | - Additional arguments are assumed to be a quoted string unless there are commas, in which case it combines them and automatically quotes only resulting arguments with spaces in them (e.g. `r gd true, publishing jekyll blogging post` runs `rake gen_deploy[true,"publishing jekyll blogging post"]` on my blog, and the `gen_deploy` task takes an argument and a git commit message). 28 | 29 | For example, in my Jekyll setup: 30 | 31 | r fdr jekyll 32 | => rake find_draft[jekyll] 33 | 34 | r fdf reiki bash 35 | => rake find_draft_fulltext["reiki bash"] 36 | 37 | r gen true, commit message 38 | => rake generate[true,"commit message"] 39 | 40 | You can separate multiple (serial) tasks with a colon (:). Arguments after a task and before a colon are treated as arguments to each task. Multiple tasks are run by rake as a series. 41 | 42 | $ r bld xcode true: launch debug 43 | => rake build[xcode,true] launch[debug] 44 | 45 | Reiki can automatically run the first match, or it can be set to verify with a question on the command line if there are multiple matches (or forced to always verify with the "quiet" option). A timeout can be set on the verification to automatically run if no response is provided. 46 | 47 | If your Terminal supports color, output will be highlighted. If not, you'll get clean output with no escape sequences. 48 | 49 | ## Installation 50 | 51 | ### Bash 52 | Add this to your `~/.bash_profile` or `~/.bashrc`: 53 | ```bash 54 | source /path/to/reiki/r.bash 55 | ``` 56 | Tab completion is automatically enabled. 57 | 58 | ### Zsh 59 | Add this to your `~/.zshrc`: 60 | ```zsh 61 | source /path/to/reiki/r.bash 62 | ``` 63 | Tab completion is automatically enabled. 64 | 65 | ### Fish 66 | Copy the Fish function to your Fish functions directory: 67 | ```fish 68 | cp /path/to/reiki/fish/functions/r.fish ~/.config/fish/functions/r.fish 69 | ``` 70 | Or add to your `~/.config/fish/config.fish`: 71 | ```fish 72 | function r 73 | bash /path/to/reiki/r.bash $argv 74 | end 75 | ``` 76 | 77 | ### Legacy Bash-it 78 | The legacy `reiki.plugin.bash` file is also available for Bash-only setups. You can drop it in your `~/.bash_it/plugins/enabled` folder if you use Bash-it. 79 | 80 | ## Configuration 81 | 82 | ### Config File 83 | Reiki looks for a config file at `~/.rrc` (or the path specified in `$R_CONFIG`). You can set defaults here: 84 | ```bash 85 | # ~/.rrc 86 | verify_task=1 # Force verification after guessing 87 | auto_timeout=5 # Seconds to wait for verification (0 to disable) 88 | quiet=1 # Silent mode, use first match if multiples found 89 | debug=1 # Verbose reporting 90 | bundle="bundle exec " # Always use bundle exec 91 | ``` 92 | 93 | ### Environment Variables 94 | You can override defaults using environment variables in your shell profile: 95 | ```bash 96 | export R_CONFIG=~/my-custom-config # Custom config file location 97 | export R_VERIFY_TASK=true # Force verification 98 | export R_AUTO_TIMEOUT=5 # Verification timeout in seconds 99 | export R_DEBUG=true # Debug mode 100 | export R_QUIET=true # Quiet mode 101 | export R_BUNDLE=true # Use "bundle exec" for all rake commands 102 | ``` 103 | Environment variables take precedence over config file settings. 104 | 105 | ## Command Line Options 106 | 107 | You can override configuration on a per-command basis: 108 | 109 | **Available options:** 110 | - `-h` - Show options summary 111 | - `-H` - Show full help with examples 112 | - `-v` - Show version number 113 | - `-V` - Interactively verify task matches before running 114 | - `-a SECONDS` - Auto-run default match after SECONDS (overrides timeout) 115 | - `-b` - Run with "bundle exec" prefix 116 | - `-q` - Run quietly (use first match, no prompts) 117 | - `-d` - Output debug information 118 | - `-T` - List all available rake tasks 119 | 120 | **Examples:** 121 | ```bash 122 | r -V deploy # Verify before running deploy task 123 | r -b test unit # Run with bundle exec: bundle exec rake test:unit 124 | r -q build prod # Quietly use first match for "build" with "prod" arg 125 | r -d gen model # Show debug output while matching "gen model" 126 | ``` 127 | 128 | ## Bash/Zsh Completion 129 | Tab completion is **automatically enabled** when you source `r.bash` in Bash or Zsh! The completion function will: 130 | - Find your Rakefile (in current directory or git repository root) 131 | - Use the cached task list (`.r_completions~`) 132 | - Regenerate the cache automatically if needed 133 | - Provide intelligent task name completion 134 | No additional setup or separate files needed. Just source `r.bash` and start using tab completion! 135 | 136 | > **Note**: The legacy `reiki.completion.bash` file is no longer needed with the improved `r.bash`. 137 | 138 | ## Resources 139 | - [Migration Guide](./MIGRATION_GUIDE.md) 140 | - [Quick Reference](./QUICK_REFERENCE.txt) 141 | - [Example Config](./dotrrc.example) 142 | 143 | -------------------------------------------------------------------------------- /reiki.plugin.bash: -------------------------------------------------------------------------------- 1 | # __ __ __ 2 | # .----.-----|__| |--|__| 3 | # | _| -__| | <| | Reiki 4 | # |__| |_____|__|__|__|__| by Brett Terpstra 2015 5 | # 6 | # MIT License 7 | # 8 | # Reiki, called with `r`, is a shortcut for running a single rake task with arguments. 9 | # No brackets, just spaces and commas, and colons to separate serial tasks if needed. 10 | # 11 | # $ r bld xcode true: launch debug 12 | # => rake build[xcode,true] launch[debug] 13 | # 14 | # Use r -H 15 | # See for more information. 16 | # 17 | # Configure the defaults: 18 | # 19 | # verify_task=1 to force a verification after guessing 20 | # auto_timeout=X to change number of seconds to wait for verification, 0 to disable 21 | # quiet=1 to force silent mode, using first option if multiple matches are found 22 | # debug=1 for verbose reporting 23 | # 24 | # Defaults can be overriden by environment variables in your login profile/rc: 25 | # 26 | # export R_VERIFY_TASK=true 27 | # export R_AUTO_TIMEOUT=5 28 | # export R_DEBUG=true 29 | # export R_QUIET=true 30 | 31 | r () { 32 | local verify_task auto_timeout quiet debug cmd 33 | 34 | ## DEFAULTS (Environment variables override) 35 | verify_task=0 36 | auto_timeout=5 37 | quiet=0 38 | debug=0 39 | ## END CONFIG 40 | 41 | [[ -n $R_VERIFY_TASK && $R_VERIFY_TASK == "true" ]] && verify_task=1 42 | [[ -n $R_AUTO_TIMEOUT ]] && auto_timeout=$R_AUTO_TIMEOUT 43 | [[ -n $R_DEBUG && $R_DEBUG == "true" ]] && debug=1 44 | [[ -n $R_QUIET && $R_QUIET == "true" ]] && quiet=1 45 | 46 | IFS='' read -r -d '' helpstring <<'ENDHELPSTRING' 47 | r: A shortcut for running rake tasks with fuzzy matching 48 | Parameters are %yellow%task fragments%reset% and %yellow%arguments%reset%, multiple arguments comma-separated 49 | 50 | ENDHELPSTRING 51 | 52 | IFS='' read -r -d '' helpoptions <<'ENDOPTIONSHELP' 53 | 54 | Example: 55 | $ %b_white%r gen dep commit message%reset% 56 | => %n_cyan%rake generate_deploy["commit message"]%reset% 57 | 58 | Options: 59 | 60 | %b_white%-h %yellow%show options%reset% 61 | %b_white%-H %yellow%show help%reset% 62 | %b_white%-T %yellow%List available tasks%reset% 63 | %b_white%-v %yellow%Interactively verify task matches before running%reset% 64 | %b_white%-a SECONDS %yellow%Prompts run default result after SECONDS%reset% 65 | %b_white%-q %yellow%Run quietly (use first match in case of multiples)%reset% 66 | %b_white%-d %yellow%Output debug info%reset% 67 | ENDOPTIONSHELP 68 | 69 | local options="" 70 | OPTIND=1 71 | while getopts "qvdTa:hH" opt; do 72 | case $opt in 73 | T) rake -T; return;; 74 | h) __color_out "$helpoptions"; return;; 75 | H) 76 | __color_out "$helpstring"; 77 | __color_out "$helpoptions"; 78 | return;; 79 | q) 80 | options+=" -q" 81 | quiet=1; verify_task=0 ;; 82 | v) 83 | options+=" -v" 84 | verify_task=1 ;; 85 | d) debug=1 ;; 86 | a) 87 | options+=" -a $OPTARG" 88 | auto_timeout=$OPTARG;; 89 | *) 90 | echo "$0: invalid flag: $1" >&2 91 | return 1 92 | esac 93 | done 94 | shift $((OPTIND-1)) 95 | 96 | 97 | IFS= 98 | local s=$(echo -e $@ | sed -E 's/ *: */:/g') 99 | eval_this="rake" 100 | 101 | IFS=$':' 102 | set $s 103 | for item; do 104 | eval_this+=" $(__r $verify_task $auto_timeout $quiet $debug $item)" 105 | done 106 | IFS= 107 | tput sgr0 108 | 109 | if [[ ! $eval_this =~ ^rake[[:space:]]+$ ]]; then 110 | eval "$eval_this" 111 | else 112 | __color_out "\n%b_red%Cancelled: %red%no command given" 113 | fi 114 | } 115 | 116 | __r () { 117 | # about 'shortcut for running rake tasks with fuzzy matching' 118 | # param 'task fragments and arguments, multiple arguments comma-separated' 119 | # example '$ r gen dep' 120 | # group 'brett' 121 | local verify_task=$1; shift 122 | local auto_timeout=$1; shift 123 | local quiet=$1; shift 124 | local debug=$1; shift 125 | IFS=" " 126 | set $@ 127 | __r_debug $debug "Received arguments $@" 128 | 129 | local toplevel=$PWD 130 | 131 | if [ ! -f Rakefile ]; then 132 | toplevel=$(git rev-parse --show-toplevel 2> /dev/null) 133 | if [[ -z $toplevel || ! -f $toplevel/Rakefile ]]; then 134 | >&2 __color_out "%red%No rakefile found\n" 135 | return 1 136 | fi 137 | fi 138 | 139 | local recent=`ls -t $toplevel/.r_completions~ $toplevel/Rakefile $toplevel/**/*.rake 2> /dev/null | head -n 1` 140 | if [[ ${recent##*/} != '.r_completions~' ]]; then 141 | ruby -rrake -e "Rake::load_rakefile('$toplevel/Rakefile'); puts Rake::Task.tasks" > .r_completions~ 142 | fi 143 | 144 | local cmd args tasks closest colorout 145 | local timeout="" 146 | local arg1=$1 147 | 148 | if [[ $# == 0 ]]; then 149 | __color_out "%red%No tasks specified. Use -h for options\n" 150 | return 151 | fi 152 | 153 | IFS=$'\n' 154 | # exact match for first arg 155 | if [[ $(grep -cE "^$1$" $toplevel/.r_completions~) == 1 ]]; then 156 | __r_debug $debug "Exact match: $1" 157 | verify_task=0 158 | cmd="$1"; shift 159 | # exact match for first arg or first arg with underscore matching second arg 160 | elif [[ $(grep -cE "^$1_$2$" $toplevel/.r_completions~) == 1 ]]; then 161 | __r_debug $debug "Exact match for multiple params: $1_$2" 162 | cmd="$1_$2"; shift; shift 163 | # Only 1 task starts with the first arg, use it and discard additional 164 | # args that would match the same task 165 | elif [[ $(grep -cE "^$1" $toplevel/.r_completions~) == 1 ]]; then 166 | __r_debug $debug "Partial match: $1" 167 | cmd=$(grep -E "^$1" $toplevel/.r_completions~); shift 168 | while [[ $cmd =~ _$(__r_to_regex "$1") ]]; do shift; done 169 | # no exact matches, check for a fuzzy match in first argument 170 | elif [[ ${#1} > 1 && $(grep -cE "^$(__r_to_regex "$1")" $toplevel/.r_completions~) > 0 ]]; then 171 | declare -a matches=( $(grep -E "^$(__r_to_regex $1)" $toplevel/.r_completions~) ) 172 | __r_debug $debug "Fuzzy matches: $(printf "%s, " "${matches[@]}")" 173 | shift 174 | while [[ $# > 0 && $(printf "%s\n" "${matches[@]}" | grep -cE "_$(__r_to_regex "$1")") > 0 ]]; do 175 | declare -a matches=( $(printf "%s\n" "${matches[@]}" | grep -E "_$(__r_to_regex "$1")") ) 176 | shift 177 | __r_debug $debug "Narrowed matches: $(printf "%s, " "${matches[@]}")" 178 | done 179 | __r_debug $debug "Fuzzy match: $(printf "%s " "${matches[@]}")" 180 | cmd=$(__r_parse_matches $quiet $auto_timeout ${matches[@]}) 181 | [[ -z $cmd ]] && return 1 182 | # multiple matches 183 | elif [[ $(grep -cE "^$1" $toplevel/.r_completions~) > 1 ]]; then 184 | declare -a matches=( $(grep -E "^$1" $toplevel/.r_completions~) ) 185 | shift 186 | __r_debug $debug "Multiple matches: $(printf "%s " "${matches[@]}")" 187 | while [[ $# > 0 && $(printf "%s\n" "${matches[@]}" | grep -cE "_$(__r_to_regex "$1")") > 0 ]]; do 188 | declare -a matches=( $(printf "%s\n" "${matches[@]}" | grep -E "_$(__r_to_regex "$1")") ) 189 | shift 190 | done 191 | cmd=$(__r_parse_matches $quiet $auto_timeout ${matches[@]}) 192 | [[ -z $cmd ]] && return 1 193 | # no matches. If the first arg is more than one character, 194 | # try recursing with first character split out 195 | # elif [[ ${#1} > 1 && $(grep -cE "^${1:0:1}" $toplevel/.r_completions~) > 0 ]]; then 196 | # firstarg=$(echo "$1"|sed -E 's/([[:alnum:]])/\1 /g'); shift 197 | # __r $verify_task $auto_timeout $quiet $debug "$firstarg $@" 198 | # return 199 | fi 200 | 201 | # if we didn't find a matching task, parse for suggestions 202 | if [[ -z $cmd ]]; then 203 | declare -a matches=( $(grep -E "^$(__r_to_regex $arg1)" $toplevel/.r_completions~) ) 204 | if [[ ${#matches[@]} == 0 ]]; then 205 | declare -a matches=( $(grep -E "^${arg1:0:1}" $toplevel/.r_completions~) ) 206 | fi 207 | if [[ ${#matches[@]} == 0 ]]; then 208 | >&2 __color_out "%red%No matching task found\n" 209 | return 1 210 | fi 211 | >&2 __color_out "%b_white%Match not found, did you mean %b_yellow%$(printf "%%yellow%%%s%%b_white%%, " ${matches[@]}|sed -E 's/ ([^ ]+), $/ or maybe %b_yellow%\1?/'|sed -E 's/, $/%b_white%?/')\n" 212 | return 1 213 | fi 214 | 215 | cmd=${cmd%%[*} 216 | [[ $verify_task == 1 && $(__r_verify_task $cmd $auto_timeout) > 0 ]] && return 1 217 | 218 | colorout="%purple%$cmd" # pretty output 219 | if [[ "$*" != "" ]]; then 220 | # quote additional arguments not separated with commas 221 | #> remove spaces following commas 222 | #> escape parens and pipes 223 | #> quote spaces between non-comma-separated args 224 | args=$(echo "$@"| sed -E 's/, */,/g' \ 225 | | sed -E 's/([\(\)\|])/\\\1/g' \ 226 | | sed -E 's/([^ ]+( +[^ ]+)+)/"\1"/') 227 | cmd="${cmd}[$args]" 228 | colorout="${colorout}%n_black%[%b_white%${args}%n_black%]" # pretty output 229 | fi 230 | 231 | [[ $quiet != 1 ]] && >&2 __color_out "%b_green%Running %n%${colorout}\n" 232 | [[ $debug == 1 && $quiet == 1 ]] && >&2 echo "Running $cmd" 233 | 234 | echo -n "$cmd" 235 | } 236 | 237 | # convert a string into a fuzzy regex 238 | __r_to_regex () { 239 | echo -n "$*"|sed -E 's/\?/\\?/g'|sed -E 's/([[:alnum:]]) */\1.*/g' 240 | } 241 | 242 | # parse an array of matches 243 | # param 1: run quietly (0,1) 244 | # param 2: auto_timeout (0,SECONDS) 245 | # param 3: match array 246 | __r_parse_matches () { 247 | __r_debug 0 $* 248 | local q=$1; shift 249 | local a=$1; shift 250 | # sort matches (remaining args) by length, ascending 251 | IFS=$'\n' GLOBIGNORE='*' matches=($(printf '%s\n' $@ | awk '{ print length($0) " " $0; }' | sort -n | cut -d ' ' -f 2-)) 252 | if [[ ${#matches[@]} > 1 && $q != 1 ]]; then 253 | verify_task=0 254 | local outstring="%red%${#matches[@]} matches %b_white%($(printf "%%purple%%%s%%b_white%%, " "${matches[@]}"|sed -E 's/, $//')%b_white%)\n" 255 | 256 | if [[ $a > 0 ]]; then 257 | outstring+="Assuming '%red%${matches[0]}%yellow%', running in ${a}s" 258 | fi 259 | >&2 __color_out "${outstring} ('%red%q%yellow%' to cancel)\n" 260 | local result cmd_match 261 | for match in ${matches[@]}; do 262 | result=$(__r_verify_task $match $a) 263 | if [[ $result == 1 ]]; then 264 | continue 265 | elif [[ $result == 127 ]]; then 266 | return 1 267 | else 268 | echo "$match" 269 | return 0 270 | fi 271 | 272 | done 273 | 274 | else 275 | echo "${matches[0]}" 276 | return 0 277 | fi 278 | } 279 | 280 | __r_debug () { 281 | if [[ $1 == 1 ]]; then 282 | shift 283 | >&2 echo -e "r: $*" 284 | fi 285 | } 286 | 287 | # Function to request verification of a task match 288 | # Y, y, or enter returns 1 289 | # any other character returns 0 290 | __r_verify_task () { 291 | local timeout="" 292 | [[ ${2-5} > 0 ]] && timeout="-t ${2-5}" 293 | 294 | read $timeout -e -n 1 -ep $'\033[1;37mRun \033[31m'"$1"$'\033[37m'"? [Y/n]: "$'\033[0m' ANSWER 295 | case $ANSWER in 296 | y|Y) echo 0;; 297 | q|Q) echo 127;; 298 | [a-zA-Z0-9]) echo 1;; 299 | *) echo 0;; 300 | esac 301 | 302 | } 303 | 304 | # Common util for color output using templates 305 | # Template format: 306 | # %colorname%: text following colored normal weight 307 | # %b% or %b_colorname%: bold foreground 308 | # %u% or %u_colorname%: underline foreground 309 | # %s% or %s_colorname%: bold foreground and background 310 | # %n% or %n_colorname%: return to normal weight 311 | # %reset%: reset foreground and background to default 312 | # Color names (prefix bg to set background): 313 | # black 314 | # red 315 | # green 316 | # yellow 317 | # cyan 318 | # purple 319 | # blue 320 | # white 321 | # @option: -n no newline 322 | # @param 1: (Required) template string to process and output 323 | __color_out () { 324 | local newline="" 325 | OPTIND=1 326 | while getopts "n" opt; do 327 | case $opt in 328 | n) newline="n";; 329 | esac 330 | done 331 | shift $((OPTIND-1)) 332 | # color output variables 333 | if which tput > /dev/null 2>&1 && [[ $(tput -T$TERM colors) -ge 8 ]]; then 334 | local _c_n="\033[0m" 335 | local _c_b="\033[1m" 336 | local _c_u="\033[4m" 337 | local _c_s="\033[7m" 338 | local _c_black="\033[30m" 339 | local _c_red="\033[31m" 340 | local _c_green="\033[32m" 341 | local _c_yellow="\033[33m" 342 | local _c_cyan="\033[34m" 343 | local _c_purple="\033[35m" 344 | local _c_blue="\033[36m" 345 | local _c_white="\033[37m" 346 | local _c_bgblack="\033[40m" 347 | local _c_bgred="\033[41m" 348 | local _c_bggreen="\033[42m" 349 | local _c_bgyellow="\033[43m" 350 | local _c_bgcyan="\033[44m" 351 | local _c_bgpurple="\033[45m" 352 | local _c_bgblue="\033[46m" 353 | local _c_bgwhite="\033[47m" 354 | local _c_reset="\033[0m" 355 | fi 356 | local template_str="echo -e${newline} \"$(echo -en "$@" \ 357 | | sed -E 's/%([busn])_/${_c_\1}%/g' \ 358 | | sed -E 's/%(bg)?(b|u|s|n|black|red|green|yellow|cyan|purple|blue|white|reset)%/${_c_\1\2}/g')$_c_reset\"" 359 | 360 | eval "$template_str" 361 | } 362 | 363 | _r_command_not_found () { 364 | echo "R NOT FOUND $@" 365 | } 366 | 367 | # Completion function for `r` 368 | __r_bash_complete() { 369 | toplevel=$PWD 370 | 371 | if [ ! -f Rakefile ]; then 372 | toplevel=$(git rev-parse --show-toplevel 2> /dev/null) 373 | if [ -z $toplevel || ! -f $toplevel/Rakefile ]; then 374 | return 1 375 | fi 376 | fi 377 | if [ -f $toplevel/Rakefile ]; then 378 | recent=`ls -t $toplevel/.r_completions~ $toplevel/Rakefile $toplevel/**/*.rake 2> /dev/null | head -n 1` 379 | if [[ $recent != '.r_completions~' ]]; then 380 | ruby -rrake -e "Rake::load_rakefile('$toplevel/Rakefile'); puts Rake::Task.tasks" > .r_completions~ 381 | fi 382 | 383 | COMPREPLY=($(compgen -W "`sort -u .r_completions~`" -- ${COMP_WORDS[COMP_CWORD]})) 384 | return 0 385 | fi 386 | } 387 | 388 | complete -o default -F __r_bash_complete r 389 | -------------------------------------------------------------------------------- /r.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # __ __ __ 3 | # .----.-----|__| |--|__| 4 | # | _| -__| | <| | Reiki (Improved) 5 | # |__| |_____|__|__|__|__| by Brett Terpstra 2015 6 | # 7 | # MIT License 8 | # 9 | # Reiki, called with `r`, is a shortcut for running a single rake task with arguments. 10 | # No brackets, just spaces and commas, and colons to separate serial tasks if needed. 11 | # 12 | # $ r bld xcode true: launch debug 13 | # => rake build[xcode,true] launch[debug] 14 | # 15 | # Use r -H 16 | # See for more information. 17 | # 18 | # Improved version with: 19 | # - Better error handling and validation 20 | # - Performance optimizations 21 | # - Safer eval usage 22 | # - Config file support 23 | # - Cache versioning 24 | # - Dependency checking 25 | # - Refactored code structure 26 | # 27 | 28 | # ============================================================================ 29 | # CONSTANTS & CONFIGURATION 30 | # ============================================================================ 31 | 32 | readonly R_VERSION="2.0.0" 33 | readonly R_CACHE_VERSION="v2" 34 | readonly R_CACHE_FILENAME=".r_completions~" 35 | 36 | # ============================================================================ 37 | # DEPENDENCY CHECKING 38 | # ============================================================================ 39 | 40 | # @description: Check if all required dependencies are available 41 | # @returns: 0 if all dependencies found, 1 otherwise 42 | __r_check_dependencies() { 43 | local -a missing=() 44 | 45 | command -v ruby >/dev/null 2>&1 || missing+=(ruby) 46 | command -v rake >/dev/null 2>&1 || missing+=(rake) 47 | 48 | if [[ ${#missing[@]} -gt 0 ]]; then 49 | echo "Error: Missing required commands: ${missing[*]}" >&2 50 | echo "Please install Ruby and Rake to use this script." >&2 51 | return 1 52 | fi 53 | 54 | return 0 55 | } 56 | 57 | # ============================================================================ 58 | # CONFIGURATION MANAGEMENT 59 | # ============================================================================ 60 | 61 | # @description: Load configuration from file and environment 62 | # @sets: Global configuration variables 63 | __r_load_config() { 64 | local config_file="${R_CONFIG:-$HOME/.rrc}" 65 | 66 | # Source config file if it exists 67 | if [[ -f "$config_file" ]]; then 68 | # shellcheck disable=SC1090 69 | source "$config_file" 2>/dev/null || true 70 | fi 71 | 72 | # Apply defaults for unset variables (environment variables take precedence) 73 | [[ -n $R_VERIFY_TASK && $R_VERIFY_TASK == "true" ]] && verify_task=1 || verify_task=${verify_task:-0} 74 | [[ -n $R_AUTO_TIMEOUT ]] && auto_timeout=$R_AUTO_TIMEOUT || auto_timeout=${auto_timeout:-5} 75 | [[ -n $R_DEBUG && $R_DEBUG == "true" ]] && debug=1 || debug=${debug:-0} 76 | [[ -n $R_QUIET && $R_QUIET == "true" ]] && quiet=1 || quiet=${quiet:-0} 77 | [[ -n $R_BUNDLE && $R_BUNDLE == "true" ]] && bundle="bundle exec " || bundle=${bundle:-""} 78 | } 79 | 80 | # ============================================================================ 81 | # TERMINAL & COLOR DETECTION 82 | # ============================================================================ 83 | 84 | # @description: Check if terminal supports color output 85 | # @returns: 0 if color supported, 1 otherwise 86 | __r_supports_color() { 87 | [[ -t 2 ]] && 88 | command -v tput >/dev/null 2>&1 && 89 | [[ $(tput -T"${TERM:-dumb}" colors 2>/dev/null || echo 0) -ge 8 ]] 90 | } 91 | 92 | # ============================================================================ 93 | # CACHE MANAGEMENT 94 | # ============================================================================ 95 | 96 | # @description: Check if cache file is valid and up-to-date 97 | # @param $1: Path to rakefile directory 98 | # @returns: 0 if cache is valid, 1 if needs regeneration 99 | __r_cache_is_valid() { 100 | local toplevel="$1" 101 | local cache_file="$toplevel/$R_CACHE_FILENAME" 102 | 103 | # Cache file must exist and not be empty 104 | [[ ! -s "$cache_file" ]] && return 1 105 | 106 | # Check version marker (first line) 107 | local first_line 108 | read -r first_line <"$cache_file" 2>/dev/null || return 1 109 | [[ "$first_line" != "#CACHE_VERSION:$R_CACHE_VERSION" ]] && return 1 110 | 111 | # Check if any rake files are newer than cache 112 | local rakefile="$toplevel/Rakefile" 113 | [[ -f "$rakefile" && "$rakefile" -nt "$cache_file" ]] && return 1 114 | 115 | # Check for newer .rake files 116 | if compgen -G "$toplevel/**/*.rake" >/dev/null 2>&1; then 117 | while IFS= read -r rake_file; do 118 | [[ "$rake_file" -nt "$cache_file" ]] && return 1 119 | done < <(find "$toplevel" -name "*.rake" -type f 2>/dev/null) 120 | fi 121 | 122 | return 0 123 | } 124 | 125 | # @description: Regenerate the task cache file atomically 126 | # @param $1: Path to rakefile directory 127 | # @param $2: Quiet mode (1 for quiet, 0 for verbose) 128 | # @returns: 0 on success, 1 on failure 129 | __r_regenerate_cache() { 130 | local toplevel="$1" 131 | local quiet="${2:-0}" 132 | local cache_file="$toplevel/$R_CACHE_FILENAME" 133 | local temp_cache="$cache_file.$$" 134 | 135 | # Show progress indicator unless in quiet mode 136 | if [[ $quiet -ne 1 ]]; then 137 | echo -n "Regenerating task cache..." >&2 138 | fi 139 | 140 | # Generate cache with version header (atomic write to temp file first) 141 | { 142 | echo "#CACHE_VERSION:$R_CACHE_VERSION" 143 | ruby -rrake -e "Rake::load_rakefile('$toplevel/Rakefile'); puts Rake::Task.tasks" 2>/dev/null 144 | } >"$temp_cache" 145 | 146 | local ret=$? 147 | 148 | if [[ $ret -eq 0 && -s "$temp_cache" ]]; then 149 | # Atomic move 150 | mv "$temp_cache" "$cache_file" 151 | [[ $quiet -ne 1 ]] && echo " done" >&2 152 | return 0 153 | else 154 | # Cleanup failed temp file 155 | rm -f "$temp_cache" 156 | [[ $quiet -ne 1 ]] && echo " failed" >&2 157 | return 1 158 | fi 159 | } 160 | 161 | # @description: Read tasks from cache file (skipping version header) 162 | # @param $1: Path to cache file 163 | # @param $2: Variable name to store results 164 | __r_read_cache() { 165 | local cache_file="$1" 166 | local arr_name="$2" 167 | 168 | # Bash 3-compatible version using eval 169 | eval "$arr_name=()" 170 | 171 | # Read all lines except the first (version header) 172 | local line_num=0 173 | while IFS= read -r line; do 174 | ((line_num++)) 175 | [[ $line_num -eq 1 ]] && continue # Skip version header 176 | eval "$arr_name+=(\"\$line\")" 177 | done <"$cache_file" 178 | } 179 | 180 | # ============================================================================ 181 | # SHELL DETECTION 182 | # ============================================================================ 183 | 184 | # @description: Detect the current shell being used 185 | # @returns: Shell name (bash, zsh, fish, ksh, dash, ash, or unknown) 186 | __r_detect_shell() { 187 | # Check for shell-specific environment variables first (most reliable) 188 | if [[ -n $BASH_VERSION ]]; then 189 | echo "bash" 190 | elif [[ -n $ZSH_VERSION ]]; then 191 | echo "zsh" 192 | elif [[ -n $FISH_VERSION ]]; then 193 | echo "fish" 194 | elif [[ -n $KSH_VERSION ]]; then 195 | echo "ksh" 196 | else 197 | # Fall back to checking $SHELL or process name 198 | local shell_name 199 | if [[ -n $SHELL ]]; then 200 | shell_name=$(basename "$SHELL") 201 | else 202 | # Try to get from process info 203 | shell_name=$(ps -p $$ -o comm= 2>/dev/null | sed 's/^-//') 204 | fi 205 | 206 | case "$shell_name" in 207 | bash | sh.bash) echo "bash" ;; 208 | zsh | sh.zsh) echo "zsh" ;; 209 | fish) echo "fish" ;; 210 | ksh | ksh93 | mksh | pdksh) echo "ksh" ;; 211 | dash) echo "dash" ;; 212 | ash) echo "ash" ;; 213 | *) echo "unknown" ;; 214 | esac 215 | fi 216 | } 217 | 218 | # ============================================================================ 219 | # STRING PROCESSING UTILITIES 220 | # ============================================================================ 221 | 222 | # @description: Read lines into array (backwards compatible replacement for mapfile) 223 | # @param $1: Array variable name to populate 224 | # @param stdin: Input lines 225 | # Usage: __r_read_lines array_name < <(command) 226 | __r_read_lines() { 227 | # Bash 3-compatible version using eval 228 | local arr_name="$1" 229 | eval "$arr_name=()" 230 | local line 231 | while IFS= read -r line; do 232 | eval "$arr_name+=(\"\$line\")" 233 | done 234 | } 235 | 236 | # @description: Convert string to fuzzy regex pattern 237 | # @param $*: String to convert 238 | # @returns: Regex pattern (via stdout) 239 | __r_to_regex() { 240 | local str="$*" 241 | # Escape special regex characters, then add [_:].* after each alphanumeric 242 | # This allows optional underscores/colons between characters for fuzzy matching 243 | str="${str//\?/\\?}" 244 | echo -n "$str" | sed -E 's/([[:alnum:]]) */\1[_:]*.*/g' 245 | } 246 | 247 | # @description: Count occurrences of a character in string (pure bash) 248 | # @param $1: String to search 249 | # @param $2: Character to count 250 | # @returns: Count (via stdout) 251 | __r_count_char() { 252 | local str="$1" 253 | local char="$2" 254 | local temp="${str//[^$char]/}" 255 | echo -n "${#temp}" 256 | } 257 | 258 | # @description: Escape special characters for rake argument passing 259 | # @param $1: String to escape 260 | # @returns: Escaped string (via stdout) 261 | __r_escape_for_rake() { 262 | local arg="$1" 263 | arg="${arg//\\/\\\\}" # Backslash 264 | arg="${arg//\"/\\\"}" # Double quote 265 | arg="${arg//\$/\\\$}" # Dollar sign 266 | echo -n "$arg" 267 | } 268 | 269 | # ============================================================================ 270 | # COMMAND VALIDATION 271 | # ============================================================================ 272 | 273 | # @description: Validate that generated command is safe to execute 274 | # @param $1: Command string to validate 275 | # @returns: 0 if valid, 1 if invalid 276 | __r_validate_command() { 277 | local cmd="$1" 278 | # Only allow rake commands with expected patterns 279 | # Pattern: (optional bundle exec) rake [task[args,args] or task or multiple tasks] 280 | # Allow: rake, rake task, rake task[args], rake task1 task2[args] 281 | if [[ "$cmd" =~ ^(bundle\ exec\ )?rake(\ .*)?$ ]]; then 282 | # Additional safety: ensure no dangerous characters like ; & | $ ` 283 | if [[ "$cmd" =~ [\;\&\$\`] ]]; then 284 | return 1 285 | fi 286 | return 0 287 | else 288 | return 1 289 | fi 290 | } 291 | 292 | # ============================================================================ 293 | # ARGUMENT PARSING 294 | # ============================================================================ 295 | 296 | # @description: Parse colon-separated arguments, preserving quoted strings 297 | # @param $@: All arguments to parse 298 | # @sets: cmds array with command indices, cmd_N arrays with command arguments 299 | __r_parse_arguments() { 300 | local -a current=() 301 | local cmd_count=0 302 | local colon_count part 303 | 304 | for arg in "$@"; do 305 | # If argument contains whitespace, it was quoted -> keep intact 306 | # If argument looks like a URL (contains ://), don't split it 307 | # Use case statement for better Bash 3 compatibility 308 | case "$arg" in 309 | *://*) 310 | # URL detected, keep intact 311 | current+=("$arg") 312 | ;; 313 | *[[:space:]]*) 314 | # Contains whitespace, was quoted, keep intact 315 | current+=("$arg") 316 | ;; 317 | *:*) 318 | # Count colons to determine number of command boundaries (pure bash) 319 | local tmp="${arg//[^:]/}" 320 | colon_count="${#tmp}" 321 | 322 | # Split on colons - use IFS and read to populate array 323 | IFS=':' read -ra parts <<<"$arg" 324 | 325 | for i in "${!parts[@]}"; do 326 | part="${parts[i]}" 327 | # Add non-empty parts to current command 328 | if [[ -n $part ]]; then 329 | current+=("$part") 330 | fi 331 | # After processing each part, check if there's a colon after it 332 | # If i < colon_count, there's a colon after this part, so save and reset 333 | if [[ $i -lt $colon_count ]]; then 334 | if [[ ${#current[@]} -gt 0 ]]; then 335 | # Store array elements with indexed variable names 336 | eval "cmd_${cmd_count}=(\"\${current[@]}\")" 337 | cmds+=("$cmd_count") 338 | ((cmd_count++)) 339 | fi 340 | # Always reset current for the next command, even if part was empty 341 | current=() 342 | fi 343 | done 344 | ;; 345 | *) 346 | current+=("$arg") 347 | ;; 348 | esac 349 | done 350 | 351 | # Push the final accumulated command, if any 352 | if [[ ${#current[@]} -gt 0 ]]; then 353 | eval "cmd_${cmd_count}=(\"\${current[@]}\")" 354 | cmds+=("$cmd_count") 355 | fi 356 | } 357 | 358 | # ============================================================================ 359 | # TASK MATCHING - User Selection 360 | # ============================================================================ 361 | 362 | # @description: Handle multiple matches with user selection 363 | # @param $1: Quiet mode flag 364 | # @param $2: Auto timeout 365 | # @param $3-$N: Matched task names 366 | # @returns: Selected task name (via stdout) 367 | __r_parse_matches() { 368 | local q="$1" 369 | shift 370 | local a="$1" 371 | shift 372 | 373 | # Sort matches by length, ascending (pure bash approach for compatibility) 374 | local -a matches 375 | local -a sorted_matches 376 | 377 | # Create length-prefixed array 378 | local -a temp_array 379 | for match in "$@"; do 380 | temp_array+=("${#match} $match") 381 | done 382 | 383 | # Sort by numeric prefix 384 | IFS=$'\n' 385 | # shellcheck disable=SC2207 386 | sorted_matches=($(printf '%s\n' "${temp_array[@]}" | sort -n | cut -d ' ' -f 2-)) 387 | unset IFS 388 | 389 | # Restore to matches array 390 | matches=("${sorted_matches[@]}") 391 | 392 | if [[ ${#matches[@]} -gt 1 && $q -ne 1 ]]; then 393 | verify_task=0 394 | local outstring="%red%${#matches[@]} matches\n" 395 | 396 | if [[ $a -gt 0 ]]; then 397 | outstring+="Assuming '%red%${matches[0]}%b_white%', %yellow%running in ${a}s" 398 | fi 399 | >&2 __color_out "${outstring} ('%red%q%yellow%' to cancel)\n" 400 | 401 | local result top_match selection 402 | top_match="${matches[0]}" 403 | 404 | result=$(__r_verify_task "$top_match" "$a") 405 | 406 | if [[ $result -eq 1 ]]; then 407 | # User wants to select different task 408 | if command -v fzf >/dev/null 2>&1; then 409 | selection=$(printf '%s\n' "${matches[@]}" | fzf --info=hidden --prompt="Select task > " --height=$((${#matches[@]} + 1)) -1 -0) 410 | if [[ -n "$selection" ]]; then 411 | echo "$selection" 412 | return 0 413 | else 414 | return 1 415 | fi 416 | else 417 | # fzf not available, use first match 418 | echo "$top_match" 419 | return 0 420 | fi 421 | elif [[ $result -eq 127 ]]; then 422 | # User cancelled 423 | return 1 424 | else 425 | # User accepted 426 | echo "$top_match" 427 | return 0 428 | fi 429 | else 430 | echo "${matches[0]}" 431 | return 0 432 | fi 433 | } 434 | 435 | # ============================================================================ 436 | # COMMAND BUILDING 437 | # ============================================================================ 438 | 439 | # @description: Build rake command string from task name and arguments 440 | # @param $1: Task name 441 | # @param $2-$N: Task arguments 442 | # @returns: Formatted rake command (via stdout) 443 | __r_build_command() { 444 | local task_name="$1" 445 | shift 446 | local cmd="$task_name" 447 | local args 448 | 449 | if [[ "$*" != "" ]]; then 450 | # Quote additional arguments not separated with commas 451 | # Remove spaces following commas 452 | # Escape parens and pipes 453 | # Quote spaces between non-comma-separated args 454 | args="$*" 455 | args="${args//, /,}" # Remove spaces after commas (pure bash) 456 | args=$(echo "$args" | sed -E 's/([\(\)\|])/\\\1/g' | sed -E 's/([^ ]+( +[^ ]+)+)/"\1"/') 457 | cmd="${cmd}[$args]" 458 | fi 459 | 460 | echo -n "$cmd" 461 | } 462 | 463 | # ============================================================================ 464 | # USER INTERACTION 465 | # ============================================================================ 466 | 467 | # @description: Request verification of a task match from user 468 | # @param $1: Task name 469 | # @param $2: Timeout in seconds (0 for no timeout) 470 | # @returns: 0 (accept), 1 (select different), 127 (cancel) 471 | __r_verify_task() { 472 | local task="$1" 473 | local timeout_secs="${2:-5}" 474 | local timeout_opt="" 475 | 476 | [[ $timeout_secs -gt 0 ]] && timeout_opt="-t $timeout_secs" 477 | 478 | # shellcheck disable=SC2086 479 | read $timeout_opt -e -n 1 -r -p $'\033[1;37mRun \033[31m'"$task"$'\033[37m'"? [Y/n]: "$'\033[0m' ANSWER 480 | 481 | case $ANSWER in 482 | y | Y | "") echo 0 ;; # Yes or Enter 483 | q | Q) echo 127 ;; # Quit 484 | *) echo 1 ;; # Any other key = select different 485 | esac 486 | } 487 | 488 | # @description: Output debug message if debug mode enabled 489 | # @param $1: Debug flag (1 = enabled) 490 | # @param $2-$N: Message to output 491 | __r_debug() { 492 | local debug_flag="$1" 493 | shift 494 | if [[ $debug_flag -eq 1 ]]; then 495 | echo -e "r: $*" >&2 496 | fi 497 | } 498 | 499 | # ============================================================================ 500 | # COLOR OUTPUT 501 | # ============================================================================ 502 | 503 | # @description: Output colored text using template format 504 | # @param $1: Template string with color codes 505 | # Template format: 506 | # %colorname%: text following colored normal weight 507 | # %b% or %b_colorname%: bold foreground 508 | # %u% or %u_colorname%: underline foreground 509 | # %s% or %s_colorname%: bold foreground and background 510 | # %n% or %n_colorname%: return to normal weight 511 | # %reset%: reset foreground and background to default 512 | # Color names: black, red, green, yellow, cyan, purple, blue, white 513 | __color_out() { 514 | local newline="" 515 | OPTIND=1 516 | while getopts "n" opt; do 517 | case $opt in 518 | n) newline="n" ;; 519 | *) ;; 520 | esac 521 | done 522 | shift $((OPTIND - 1)) 523 | 524 | # Only use colors if terminal supports them 525 | if __r_supports_color; then 526 | local _c_n="\033[0m" 527 | local _c_b="\033[1m" 528 | local _c_u="\033[4m" 529 | local _c_s="\033[7m" 530 | local _c_black="\033[30m" 531 | local _c_red="\033[31m" 532 | local _c_green="\033[32m" 533 | local _c_yellow="\033[33m" 534 | local _c_cyan="\033[34m" 535 | local _c_purple="\033[35m" 536 | local _c_blue="\033[36m" 537 | local _c_white="\033[37m" 538 | local _c_bgblack="\033[40m" 539 | local _c_bgred="\033[41m" 540 | local _c_bggreen="\033[42m" 541 | local _c_bgyellow="\033[43m" 542 | local _c_bgcyan="\033[44m" 543 | local _c_bgpurple="\033[45m" 544 | local _c_bgblue="\033[46m" 545 | local _c_bgwhite="\033[47m" 546 | local _c_reset="\033[0m" 547 | 548 | local template_str 549 | template_str="echo -e${newline} \"$(echo -en "$@" | 550 | sed -E 's/%([busn])_/${_c_\1}%/g' | 551 | sed -E 's/%(bg)?(b|u|s|n|black|red|green|yellow|cyan|purple|blue|white|reset)%/${_c_\1\2}/g')$_c_reset\" >&2" 552 | 553 | eval "$template_str" 554 | else 555 | # No color support, strip color codes and output plain text 556 | echo -e${newline} "$(echo -en "$@" | sed -E 's/%[a-z_]*%//g')" >&2 557 | fi 558 | } 559 | 560 | # ============================================================================ 561 | # CORE TASK EXECUTION 562 | # ============================================================================ 563 | 564 | # @description: Main task resolution and execution function 565 | # @param $1: verify_task flag 566 | # @param $2: auto_timeout value 567 | # @param $3: quiet flag 568 | # @param $4: debug flag 569 | # @param $5: retry attempt number (0 for first attempt) 570 | # @param $6-$N: Task fragments and arguments 571 | # @returns: Rake command string (via stdout), exit code 0 on success 572 | __r() { 573 | local verify_task="$1" 574 | shift 575 | local auto_timeout="$1" 576 | shift 577 | local quiet="$1" 578 | shift 579 | local debug="$1" 580 | shift 581 | local retry="${1:-0}" 582 | shift 583 | 584 | # Reset positional parameters to remaining args, preserving quoted strings 585 | set -- "$@" 586 | __r_debug "$debug" "Received arguments: $*" 587 | 588 | # Find rakefile directory 589 | local toplevel="$PWD" 590 | 591 | if [[ ! -f Rakefile ]]; then 592 | if command -v git >/dev/null 2>&1; then 593 | toplevel=$(git rev-parse --show-toplevel 2>/dev/null) 594 | fi 595 | 596 | if [[ -z $toplevel || ! -f $toplevel/Rakefile ]]; then 597 | >&2 __color_out "%red%No Rakefile found\n" 598 | return 1 599 | fi 600 | fi 601 | 602 | # Validate/regenerate cache 603 | if ! __r_cache_is_valid "$toplevel"; then 604 | if ! __r_regenerate_cache "$toplevel" "$quiet"; then 605 | >&2 __color_out "%red%Error loading Rakefile\n" 606 | return 1 607 | fi 608 | fi 609 | 610 | local cache_file="$toplevel/$R_CACHE_FILENAME" 611 | 612 | # Check if cache file is readable and not empty 613 | if [[ ! -s "$cache_file" ]]; then 614 | >&2 __color_out "%red%No rake tasks found\n" 615 | return 1 616 | fi 617 | 618 | # Check for no arguments 619 | if [[ $# -eq 0 ]]; then 620 | __color_out "%red%No tasks specified. Use -h for options\n" 621 | return 1 622 | fi 623 | 624 | # Store original first arg for error messages 625 | local arg1="$1" 626 | local cmd="" 627 | local match_count 628 | 629 | # Perform task matching inline to properly handle argument shifting 630 | # First, check if combining first and second args matches a task (higher priority) 631 | # This ensures 'r edit url' matches 'edit_url' instead of 'edit' with arg 'url' 632 | if [[ $# -ge 2 ]]; then 633 | match_count=$(grep -cE "^$1[_:]$2$" "$cache_file" 2>/dev/null || echo 0) 634 | match_count=${match_count//[$'\n\r\t ']/} # Strip all whitespace 635 | if [[ $match_count -eq 1 ]]; then 636 | __r_debug "$debug" "Exact match for multiple params: $1_$2" 637 | verify_task=0 638 | cmd="$1_$2" 639 | shift 2 640 | fi 641 | fi 642 | 643 | # Exact match for first arg (only if not already matched) 644 | if [[ -z $cmd ]]; then 645 | match_count=$(grep -cE "^$1$" "$cache_file" 2>/dev/null || echo 0) 646 | match_count=${match_count//[$'\n\r\t ']/} # Strip all whitespace 647 | if [[ $match_count -eq 1 ]]; then 648 | __r_debug "$debug" "Exact match: $1" 649 | verify_task=0 650 | cmd="$1" 651 | shift 652 | fi 653 | fi 654 | 655 | # Only 1 task starts with the first arg (if not already matched) 656 | if [[ -z $cmd ]]; then 657 | match_count=$(grep -cE "^$1" "$cache_file" 2>/dev/null || echo 0) 658 | match_count=${match_count//[$'\n\r\t ']/} # Strip all whitespace 659 | if [[ $match_count -eq 1 ]]; then 660 | __r_debug "$debug" "Partial match: $1" 661 | cmd=$(grep -E "^$1" "$cache_file") 662 | shift 663 | while [[ $# -gt 0 && $cmd =~ _$(__r_to_regex "$1") ]]; do 664 | shift 665 | done 666 | fi 667 | fi 668 | 669 | # Fuzzy match in first argument (if not already matched) 670 | if [[ -z $cmd && ${#arg1} -gt 1 ]]; then 671 | match_count=$(grep -cE "^$(__r_to_regex "$arg1")" "$cache_file" 2>/dev/null || echo 0) 672 | match_count=${match_count//[$'\n\r\t ']/} # Strip all whitespace 673 | if [[ $match_count -gt 0 ]]; then 674 | local -a matches 675 | __r_read_lines matches < <(grep -E "^$(__r_to_regex "$arg1")" "$cache_file" 2>/dev/null) 676 | __r_debug "$debug" "Fuzzy matches: $(printf "%s, " "${matches[@]}")" 677 | shift 678 | 679 | while [[ $# -gt 0 ]]; do 680 | local narrow_count 681 | narrow_count=$(printf "%s\n" "${matches[@]}" | grep -cE "_$(__r_to_regex "$1")" 2>/dev/null || echo 0) 682 | # Ensure we have a single numeric token (strip extra whitespace and non-digits) 683 | narrow_count="${narrow_count%% *}" 684 | narrow_count="${narrow_count//[^0-9]/}" 685 | narrow_count="${narrow_count:-0}" 686 | 687 | if [[ $narrow_count -gt 0 ]]; then 688 | __r_read_lines matches < <(printf "%s\n" "${matches[@]}" | grep -E "_$(__r_to_regex "$1")") 689 | shift 690 | __r_debug "$debug" "Narrowed matches: $(printf "%s, " "${matches[@]}")" 691 | else 692 | break 693 | fi 694 | done 695 | 696 | __r_debug "$debug" "Fuzzy match: $(printf "%s " "${matches[@]}")" 697 | cmd=$(__r_parse_matches "$quiet" "$auto_timeout" "${matches[@]}") 698 | [[ -z $cmd ]] && return 1 699 | fi 700 | fi 701 | 702 | # Multiple prefix matches (if not already matched) 703 | if [[ -z $cmd ]]; then 704 | match_count=$(grep -cE "^$arg1" "$cache_file" 2>/dev/null || echo 0) 705 | match_count=${match_count//[$'\n\r\t ']/} # Strip all whitespace 706 | if [[ $match_count -gt 1 ]]; then 707 | local -a matches 708 | __r_read_lines matches < <(grep -E "^$arg1" "$cache_file" 2>/dev/null) 709 | __r_debug "$debug" "Multiple matches: $(printf "%s " "${matches[@]}")" 710 | shift 711 | 712 | while [[ $# -gt 0 ]]; do 713 | local narrow_count 714 | narrow_count=$(printf "%s\n" "${matches[@]}" | grep -cE "_$(__r_to_regex "$1")" 2>/dev/null || echo 0) 715 | # Ensure we have a single numeric token (strip extra whitespace and non-digits) 716 | narrow_count="${narrow_count%% *}" 717 | narrow_count="${narrow_count//[^0-9]/}" 718 | i narrow_count="${narrow_count:-0}" 719 | 720 | if [[ $narrow_count -gt 0 ]]; then 721 | __r_read_lines matches < <(printf "%s\n" "${matches[@]}" | grep -E "_$(__r_to_regex "$1")") 722 | shift 723 | else 724 | break 725 | fi 726 | done 727 | 728 | cmd=$(__r_parse_matches "$quiet" "$auto_timeout" "${matches[@]}") 729 | [[ -z $cmd ]] && return 1 730 | fi 731 | fi 732 | 733 | # If we didn't find a matching task, try suggestions or retry 734 | if [[ -z $cmd ]]; then 735 | local -a matches 736 | __r_read_lines matches < <(grep -E "^$(__r_to_regex "$arg1")" "$cache_file" 2>/dev/null) 737 | 738 | if [[ ${#matches[@]} -eq 0 ]]; then 739 | # Try first character match 740 | __r_read_lines matches < <(grep -E "^${arg1:0:1}" "$cache_file" 2>/dev/null) 741 | fi 742 | 743 | if [[ ${#matches[@]} -eq 0 ]]; then 744 | # No matches found at all 745 | if [[ $retry -eq 0 ]]; then 746 | # First attempt failed - delete cache and retry once 747 | rm -f "$cache_file" 748 | >&2 __color_out "%yellow%No match found, regenerating task list and retrying...\n" 749 | 750 | local retry_result retry_ret 751 | retry_result=$(__r "$verify_task" "$auto_timeout" "$quiet" "$debug" 1 "$@") 752 | retry_ret=$? 753 | 754 | if [[ $retry_ret -eq 0 ]]; then 755 | # Retry succeeded, return its result 756 | echo -n "$retry_result" 757 | return 0 758 | else 759 | # Retry also failed, error already shown by recursive call 760 | return 1 761 | fi 762 | else 763 | # Already retried once, give up 764 | >&2 __color_out "%red%No matching task found\n" 765 | return 1 766 | fi 767 | fi 768 | 769 | # Show suggestions 770 | >&2 __color_out "%b_white%Match not found, did you mean %b_yellow%$(printf "%%yellow%%%s%%b_white%%, " "${matches[@]}" | sed -E 's/ ([^ ]+), $/ or maybe %b_yellow%\1?/' | sed -E 's/, $/%b_white%?/')\n" 771 | return 1 772 | fi 773 | 774 | # Remove task name suffix if it has brackets 775 | cmd="${cmd%%\[*}" 776 | 777 | # Verify task if requested 778 | if [[ $verify_task -eq 1 ]]; then 779 | local verify_result 780 | verify_result=$(__r_verify_task "$cmd" "$auto_timeout") 781 | [[ $verify_result -gt 0 ]] && return 1 782 | fi 783 | 784 | # Build full command with arguments 785 | local full_cmd 786 | full_cmd=$(__r_build_command "$cmd" "$@") 787 | 788 | # Pretty output 789 | local colorout="%purple%$cmd" 790 | if [[ "$*" != "" ]]; then 791 | local display_args 792 | display_args=$(echo "$@" | sed -E 's/, */,/g' | 793 | sed -E 's/([\(\)\|])/\\\1/g' | 794 | sed -E 's/([^ ]+( +[^ ]+)+)/"\1"/') 795 | colorout="${colorout}%n_black%[%b_white%${display_args}%n_black%]" 796 | fi 797 | 798 | if [[ $quiet -ne 1 ]]; then 799 | __color_out "%b_green%Running %n%${colorout}\n" 800 | fi 801 | 802 | if [[ $debug -eq 1 && $quiet -eq 1 ]]; then 803 | echo "Running $full_cmd" >&2 804 | fi 805 | 806 | echo -n "$full_cmd" 807 | } 808 | 809 | # ============================================================================ 810 | # MAIN ENTRY POINT 811 | # ============================================================================ 812 | 813 | # @description: Main entry point - parse options and execute rake tasks 814 | # @param $@: Command line arguments 815 | _r() { 816 | # Check bash version 817 | if [[ ${BASH_VERSINFO[0]} -lt 3 ]]; then 818 | echo "Error: Bash 3.0 or higher required" >&2 819 | return 1 820 | fi 821 | 822 | # Check dependencies 823 | if ! __r_check_dependencies; then 824 | return 1 825 | fi 826 | 827 | # Load configuration 828 | local verify_task auto_timeout quiet debug bundle 829 | __r_load_config 830 | 831 | # Help strings 832 | IFS='' read -r -d '' helpstring <<'ENDHELPSTRING' 833 | r: A shortcut for running rake tasks with fuzzy matching 834 | Parameters are %yellow%task fragments%reset% and %yellow%arguments%reset%, multiple arguments comma-separated 835 | 836 | ENDHELPSTRING 837 | 838 | IFS='' read -r -d '' helpoptions <<'ENDOPTIONSHELP' 839 | 840 | Example: 841 | $ %b_white%r gen dep commit message%reset% 842 | => %n_cyan%rake generate_deploy["commit message"]%reset% 843 | 844 | Options: 845 | 846 | %b_white%-h %yellow%show options%reset% 847 | %b_white%-H %yellow%show help%reset% 848 | %b_white%-a SECONDS %yellow%Prompts run default result after SECONDS%reset% 849 | %b_white%-b %yellow%Run with "bundle exec"%reset% 850 | %b_white%-d %yellow%Output debug info%reset% 851 | %b_white%-T %yellow%List available tasks%reset% 852 | %b_white%-q %yellow%Run quietly (use first match in case of multiples)%reset% 853 | %b_white%-v %yellow%Show version%reset% 854 | %b_white%-V %yellow%Interactively verify task matches before running%reset% 855 | ENDOPTIONSHELP 856 | 857 | # Parse command line options 858 | OPTIND=1 859 | while getopts "bqvdTVa:hH" opt; do 860 | case $opt in 861 | T) 862 | rake -T 863 | return 864 | ;; 865 | v) 866 | echo "r (Reiki) version $R_VERSION" 867 | return 868 | ;; 869 | b) bundle="bundle exec " ;; 870 | h) 871 | __color_out "$helpoptions" 872 | return 873 | ;; 874 | H) 875 | __color_out "$helpstring" 876 | __color_out "$helpoptions" 877 | return 878 | ;; 879 | q) 880 | quiet=1 881 | verify_task=0 882 | ;; 883 | V) 884 | verify_task=1 885 | ;; 886 | d) debug=1 ;; 887 | a) 888 | auto_timeout="$OPTARG" 889 | ;; 890 | *) 891 | echo "$0: invalid flag: -$OPTARG" >&2 892 | return 1 893 | ;; 894 | esac 895 | done 896 | shift $((OPTIND - 1)) 897 | 898 | # Parse arguments into commands (split on colons, preserve quoted strings) 899 | local -a cmds=() 900 | __r_parse_arguments "$@" 901 | 902 | # Build rake command string 903 | local eval_this="${bundle}rake" 904 | local result ret 905 | 906 | for idx in "${cmds[@]}"; do 907 | eval "cmd_args=(\"\${cmd_${idx}[@]}\")" 908 | result=$(__r "$verify_task" "$auto_timeout" "$quiet" "$debug" 0 "${cmd_args[@]}") 909 | ret=$? 910 | 911 | if [[ $ret -ne 0 ]]; then 912 | # __r failed (no match found, even after retry) 913 | return "$ret" 914 | fi 915 | 916 | if [[ -n $result ]]; then 917 | eval_this+=" $result" 918 | fi 919 | done 920 | 921 | # Reset terminal 922 | command -v tput >/dev/null 2>&1 && tput sgr0 923 | 924 | # Validate and execute command 925 | if [[ ! $eval_this =~ ^(bundle\ exec\ )?rake[[:space:]]*$ ]]; then 926 | if __r_validate_command "$eval_this"; then 927 | eval "$eval_this" 928 | else 929 | >&2 __color_out "%red%Error: Invalid command generated: %reset%$eval_this\n" 930 | return 1 931 | fi 932 | else 933 | __color_out "\n%b_red%Cancelled: %red%no command given" 934 | fi 935 | } 936 | 937 | # ============================================================================ 938 | # BASH COMPLETION 939 | # ============================================================================ 940 | 941 | # @description: Bash completion function for r command 942 | # Automatically enabled when r.bash is sourced in bash/zsh 943 | _r_completion() { 944 | local cur="${COMP_WORDS[COMP_CWORD]}" 945 | local toplevel="$PWD" 946 | 947 | # Find rakefile directory if not in current dir 948 | if [[ ! -f Rakefile ]]; then 949 | if command -v git >/dev/null 2>&1; then 950 | toplevel=$(git rev-parse --show-toplevel 2>/dev/null) 951 | fi 952 | 953 | if [[ -z $toplevel || ! -f $toplevel/Rakefile ]]; then 954 | return 1 955 | fi 956 | fi 957 | 958 | local cache_file="$toplevel/$R_CACHE_FILENAME" 959 | 960 | # Ensure cache exists and is valid 961 | if ! __r_cache_is_valid "$toplevel"; then 962 | __r_regenerate_cache "$toplevel" 1 >/dev/null 2>&1 || return 1 963 | fi 964 | 965 | if [[ -f "$cache_file" ]]; then 966 | local -a tasks 967 | __r_read_cache "$cache_file" tasks 968 | # shellcheck disable=SC2207 969 | COMPREPLY=($(compgen -W "${tasks[*]}" -- "$cur")) 970 | fi 971 | } 972 | 973 | # ============================================================================ 974 | # EXECUTE 975 | # ============================================================================ 976 | 977 | # @description: Determine if script is being sourced or executed 978 | # @returns: 0 if sourced, 1 if executed directly 979 | __r_is_sourced() { 980 | # In bash, check if BASH_SOURCE and $0 differ 981 | if [[ -n $BASH_VERSION ]]; then 982 | [[ "${BASH_SOURCE[0]}" != "$0" ]] 983 | return $? 984 | fi 985 | 986 | # In zsh, check if sourced is in funcstack/functrace 987 | if [[ -n $ZSH_VERSION ]]; then 988 | [[ ${#functrace[@]} -gt 0 ]] 989 | return $? 990 | fi 991 | 992 | # For other shells, assume sourced if $0 doesn't match this script 993 | local script_name 994 | script_name=$(basename "${BASH_SOURCE[0]:-$0}") 995 | [[ "$script_name" != "r.bash" ]] 996 | } 997 | 998 | # @description: Set up shell-specific 'r' function wrapper 999 | __r_setup_wrapper() { 1000 | local detected_shell 1001 | detected_shell=$(__r_detect_shell) 1002 | local script_path="${BASH_SOURCE[0]:-$0}" 1003 | 1004 | # Get absolute path to this script 1005 | if [[ -L "$script_path" ]]; then 1006 | script_path=$(readlink -f "$script_path" 2>/dev/null || readlink "$script_path" 2>/dev/null || echo "$script_path") 1007 | fi 1008 | [[ "$script_path" != /* ]] && script_path="$PWD/$script_path" 1009 | 1010 | case "$detected_shell" in 1011 | bash | zsh | ksh) 1012 | # For bash-like shells, create a simple wrapper that calls _r 1013 | # This preserves all the shell-native functionality 1014 | eval "r() { _r \"\$@\"; }" 1015 | 1016 | # Enable bash completion for bash and zsh 1017 | if [[ "$detected_shell" == "bash" ]] || [[ "$detected_shell" == "zsh" ]]; then 1018 | complete -F _r_completion r 2>/dev/null || true 1019 | fi 1020 | ;; 1021 | fish) 1022 | # For fish, we need to output fish function syntax 1023 | # This will be evaluated when the user sources the script 1024 | echo "function r; bash \"$script_path\" \$argv; end" >&2 1025 | echo "Fish shell detected. Add the above function to your config.fish or run it directly." >&2 1026 | echo "Note: In fish, run: source \"$script_path\" | source" >&2 1027 | ;; 1028 | dash | ash) 1029 | # For simpler POSIX shells, create a basic function 1030 | eval "r() { \"$script_path\" \"\$@\"; }" 1031 | ;; 1032 | *) 1033 | # Unknown shell - try a generic approach 1034 | echo "Warning: Unknown shell detected. Creating generic wrapper." >&2 1035 | eval "r() { \"$script_path\" \"\$@\"; }" 1036 | ;; 1037 | esac 1038 | } 1039 | 1040 | # Main execution logic 1041 | if __r_is_sourced; then 1042 | # Script is being sourced - set up the wrapper function 1043 | __r_setup_wrapper 1044 | else 1045 | # Script is being executed directly - run _r with all arguments 1046 | _r "$@" 1047 | fi 1048 | --------------------------------------------------------------------------------