@@ -12,12 +12,15 @@ pub fn suggest(command: &str) -> Option<String> {
1212 if command. is_empty ( )
1313 || starts_with_rtk ( command) && preferred_rtk_command ( command)
1414 || is_preferred_pwsh_wrapper ( command)
15+ || is_preferred_bash_wrapper ( command)
1516 {
1617 return None ;
1718 }
1819
1920 direct_powershell_redirect ( command)
2021 . or_else ( || powershell_redirect ( command) )
22+ . or_else ( || env_redirect ( command) )
23+ . or_else ( || bash_redirect ( command) )
2124 . or_else ( || posix_redirect ( command) )
2225 . or_else ( || rg_redirect ( command) )
2326 . or_else ( || safe_external_rtk_rewrite ( command) )
@@ -56,6 +59,7 @@ fn preferred_rtk_command(command: &str) -> bool {
5659 | "curl"
5760 )
5861 ) || is_preferred_pwsh_wrapper ( command)
62+ || is_preferred_bash_wrapper ( command)
5963}
6064
6165fn starts_with_rtk ( command : & str ) -> bool {
@@ -146,6 +150,23 @@ fn is_preferred_pwsh_wrapper(command: &str) -> bool {
146150 } )
147151}
148152
153+ fn is_preferred_bash_wrapper ( command : & str ) -> bool {
154+ if starts_with_rtk ( command) {
155+ return false ;
156+ }
157+ if tokenize ( command)
158+ . first ( )
159+ . is_none_or ( |token| command_name ( & token. text ) != "bash" )
160+ {
161+ return false ;
162+ }
163+
164+ inner_bash_command ( command) . is_some_and ( |( _, inner) | {
165+ let tokens = tokenize ( & inner) ;
166+ find_test_tool_invocation ( & tokens) . is_some_and ( |( _, _, _, already_rtk) | already_rtk)
167+ } )
168+ }
169+
149170fn inner_powershell_command ( command : & str ) -> Option < String > {
150171 let tokens = tokenize ( command) ;
151172 if !tokens
@@ -167,6 +188,25 @@ fn inner_powershell_command(command: &str) -> Option<String> {
167188 Some ( inner)
168189}
169190
191+ fn inner_bash_command ( command : & str ) -> Option < ( String , String ) > {
192+ let tokens = tokenize ( command) ;
193+ if tokens
194+ . first ( )
195+ . is_none_or ( |token| command_name ( & token. text ) != "bash" )
196+ {
197+ return None ;
198+ }
199+
200+ let command_token = tokens. iter ( ) . position ( |token| {
201+ matches ! ( token. text. as_str( ) , "-c" | "-lc" | "-cl" )
202+ || token. text . starts_with ( '-' ) && token. text . contains ( 'c' ) && !token. text . contains ( 'n' )
203+ } ) ?;
204+ let option = tokens[ command_token] . text . clone ( ) ;
205+ let start = tokens. get ( command_token + 1 ) ?. start ;
206+ let inner = strip_outer_quotes ( command[ start..] . trim ( ) ) . to_string ( ) ;
207+ Some ( ( option, inner) )
208+ }
209+
170210fn strip_outer_quotes ( value : & str ) -> & str {
171211 let bytes = value. as_bytes ( ) ;
172212 if bytes. len ( ) >= 2
@@ -209,6 +249,117 @@ fn contains_output_redirection(command: &str) -> bool {
209249 false
210250}
211251
252+ fn env_redirect ( command : & str ) -> Option < String > {
253+ let had_rtk_prefix = strip_rtk_prefix ( command) . is_some ( ) ;
254+ let without_rtk = strip_rtk_prefix ( command) . unwrap_or ( command) ;
255+ let tokens = tokenize ( without_rtk) ;
256+ if tokens
257+ . first ( )
258+ . is_none_or ( |token| command_name ( & token. text ) != "env" )
259+ {
260+ return None ;
261+ }
262+
263+ let mut index = 1 ;
264+ while tokens
265+ . get ( index)
266+ . is_some_and ( |token| is_env_assignment ( & token. text ) )
267+ {
268+ index += 1 ;
269+ }
270+ if index == 1 || index >= tokens. len ( ) {
271+ return None ;
272+ }
273+
274+ let ( command_index, tool_index, tool, already_rtk) = find_test_tool_invocation (
275+ & tokens[ index..] ,
276+ )
277+ . map ( |( command_index, tool_index, tool, already_rtk) | {
278+ ( command_index + index, tool_index + index, tool, already_rtk)
279+ } ) ?;
280+ if command_index != index || already_rtk && !had_rtk_prefix {
281+ return None ;
282+ }
283+ if tokens[ tool_index + 1 ..]
284+ . iter ( )
285+ . any ( |token| matches ! ( token. text. as_str( ) , "|" | ";" ) )
286+ {
287+ return None ;
288+ }
289+
290+ let assignments = tokens[ 1 ..index]
291+ . iter ( )
292+ . map ( |token| token. text . as_str ( ) )
293+ . collect :: < Vec < _ > > ( )
294+ . join ( " " ) ;
295+ let args = join_args ( & tokens[ tool_index + 1 ..] ) ;
296+ Some ( format ! ( "env {assignments} rtk {tool}{args}" ) )
297+ }
298+
299+ fn bash_redirect ( command : & str ) -> Option < String > {
300+ let had_rtk_prefix = strip_rtk_prefix ( command) . is_some ( ) ;
301+ let without_rtk = strip_rtk_prefix ( command) . unwrap_or ( command) ;
302+ let ( option, inner) = inner_bash_command ( without_rtk) ?;
303+ let rewritten = shell_inner_test_tool_redirect ( & inner) ?;
304+ if rewritten == inner && !had_rtk_prefix {
305+ return None ;
306+ }
307+ Some ( format ! (
308+ "bash {option} {}" ,
309+ quote_bash_command_arg( & rewritten)
310+ ) )
311+ }
312+
313+ fn shell_inner_test_tool_redirect ( inner : & str ) -> Option < String > {
314+ if shell_inner_has_unsupported_control ( inner) {
315+ return None ;
316+ }
317+ let tokens = tokenize ( inner) ;
318+ let ( command_index, tool_index, tool, already_rtk) = find_test_tool_invocation ( & tokens) ?;
319+ if tokens[ ..command_index]
320+ . iter ( )
321+ . any ( |token| !is_env_assignment ( & token. text ) )
322+ {
323+ return None ;
324+ }
325+
326+ let prefix = inner[ ..tokens[ command_index] . start ] . trim_end ( ) ;
327+ let args = join_args ( & tokens[ tool_index + 1 ..] ) ;
328+ let mut rewritten = String :: new ( ) ;
329+ if !prefix. is_empty ( ) {
330+ rewritten. push_str ( prefix) ;
331+ rewritten. push ( ' ' ) ;
332+ }
333+ rewritten. push_str ( "rtk " ) ;
334+ rewritten. push_str ( & tool) ;
335+ rewritten. push_str ( & args) ;
336+ if already_rtk && rewritten == inner {
337+ return Some ( inner. to_string ( ) ) ;
338+ }
339+ Some ( rewritten)
340+ }
341+
342+ fn shell_inner_has_unsupported_control ( inner : & str ) -> bool {
343+ inner. contains ( '\n' )
344+ || inner. contains ( '|' )
345+ || inner. contains ( "&&" )
346+ || inner. contains ( "||" )
347+ || inner. contains ( '<' )
348+ || inner. contains ( '>' )
349+ || inner. contains ( ';' )
350+ }
351+
352+ fn is_env_assignment ( value : & str ) -> bool {
353+ let Some ( ( name, _) ) = value. split_once ( '=' ) else {
354+ return false ;
355+ } ;
356+ !name. is_empty ( )
357+ && name
358+ . chars ( )
359+ . all ( |ch| ch == '_' || ch. is_ascii_alphanumeric ( ) )
360+ && !name. as_bytes ( ) [ 0 ] . is_ascii_digit ( )
361+ }
362+
212363fn content_redirect ( command : & str ) -> Option < String > {
213364 let tokens = tokenize ( command) ;
214365 if !tokens
@@ -334,17 +485,22 @@ fn test_tool_redirect(inner: &str) -> Option<String> {
334485}
335486
336487fn find_test_tool ( tokens : & [ Token ] ) -> Option < ( usize , String ) > {
488+ let ( _, tool_index, tool, _) = find_test_tool_invocation ( tokens) ?;
489+ Some ( ( tool_index, tool) )
490+ }
491+
492+ fn find_test_tool_invocation ( tokens : & [ Token ] ) -> Option < ( usize , usize , String , bool ) > {
337493 for ( index, token) in tokens. iter ( ) . enumerate ( ) {
338494 let name = command_name ( & token. text ) ;
339495 if matches ! ( name. as_str( ) , "busted" | "luacheck" ) {
340- return Some ( ( index, name) ) ;
496+ return Some ( ( index, index , name, false ) ) ;
341497 }
342498 if name == "rtk"
343499 && let Some ( next) = tokens. get ( index + 1 )
344500 {
345501 let next_name = command_name ( & next. text ) ;
346502 if matches ! ( next_name. as_str( ) , "busted" | "luacheck" ) {
347- return Some ( ( index + 1 , next_name) ) ;
503+ return Some ( ( index, index + 1 , next_name, true ) ) ;
348504 }
349505 }
350506 }
@@ -1102,6 +1258,10 @@ fn quote_powershell_command_arg(value: &str) -> String {
11021258 format ! ( "'{}'" , value. replace( '\'' , "''" ) )
11031259}
11041260
1261+ fn quote_bash_command_arg ( value : & str ) -> String {
1262+ format ! ( "'{}'" , value. replace( '\'' , r#"'\''"# ) )
1263+ }
1264+
11051265#[ cfg( test) ]
11061266mod tests {
11071267 use super :: * ;
0 commit comments