├── .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 |
--------------------------------------------------------------------------------