rubish-gem 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.dockerignore +23 -0
  3. data/Dockerfile +54 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +39 -0
  6. data/Rakefile +12 -0
  7. data/lib/rubish/arithmetic.rb +140 -0
  8. data/lib/rubish/ast.rb +168 -0
  9. data/lib/rubish/builtins/arithmetic.rb +129 -0
  10. data/lib/rubish/builtins/bind_readline.rb +834 -0
  11. data/lib/rubish/builtins/directory_stack.rb +182 -0
  12. data/lib/rubish/builtins/echo_printf.rb +510 -0
  13. data/lib/rubish/builtins/hash_directories.rb +260 -0
  14. data/lib/rubish/builtins/read.rb +299 -0
  15. data/lib/rubish/builtins/trap.rb +324 -0
  16. data/lib/rubish/codegen.rb +1273 -0
  17. data/lib/rubish/completion.rb +840 -0
  18. data/lib/rubish/completions/bash_helpers.rb +530 -0
  19. data/lib/rubish/completions/git.rb +431 -0
  20. data/lib/rubish/completions/help_parser.rb +453 -0
  21. data/lib/rubish/completions/ssh.rb +114 -0
  22. data/lib/rubish/config.rb +267 -0
  23. data/lib/rubish/data/builtin_help.rb +716 -0
  24. data/lib/rubish/data/completion_data.rb +53 -0
  25. data/lib/rubish/data/readline_config.rb +47 -0
  26. data/lib/rubish/data/shell_options.rb +251 -0
  27. data/lib/rubish/data_define.rb +65 -0
  28. data/lib/rubish/execution_context.rb +1124 -0
  29. data/lib/rubish/expansion.rb +988 -0
  30. data/lib/rubish/history.rb +663 -0
  31. data/lib/rubish/lazy_loader.rb +127 -0
  32. data/lib/rubish/lexer.rb +1194 -0
  33. data/lib/rubish/parser.rb +1167 -0
  34. data/lib/rubish/prompt.rb +766 -0
  35. data/lib/rubish/repl.rb +2267 -0
  36. data/lib/rubish/runtime/builtins.rb +7222 -0
  37. data/lib/rubish/runtime/command.rb +1153 -0
  38. data/lib/rubish/runtime/job.rb +153 -0
  39. data/lib/rubish/runtime.rb +1169 -0
  40. data/lib/rubish/shell_state.rb +241 -0
  41. data/lib/rubish/startup_profiler.rb +67 -0
  42. data/lib/rubish/version.rb +5 -0
  43. data/lib/rubish.rb +60 -0
  44. data/sig/rubish.rbs +4 -0
  45. metadata +85 -0
@@ -0,0 +1,1273 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubish
4
+ class Codegen
5
+ def generate(node)
6
+ case node
7
+ when AST::Command
8
+ generate_command(node)
9
+ when AST::Pipeline
10
+ generate_pipeline(node)
11
+ when AST::Negation
12
+ generate_negation(node)
13
+ when AST::List
14
+ generate_list(node)
15
+ when AST::Redirect
16
+ generate_redirect(node)
17
+ when AST::VarnameRedirect
18
+ generate_varname_redirect(node)
19
+ when AST::Background
20
+ generate_background(node)
21
+ when AST::And
22
+ generate_and(node)
23
+ when AST::Or
24
+ generate_or(node)
25
+ when AST::If
26
+ generate_if(node)
27
+ when AST::Unless
28
+ generate_unless(node)
29
+ when AST::While
30
+ generate_while(node)
31
+ when AST::Until
32
+ generate_until(node)
33
+ when AST::For
34
+ generate_for(node)
35
+ when AST::ArithFor
36
+ generate_arith_for(node)
37
+ when AST::Select
38
+ generate_select(node)
39
+ when AST::Function
40
+ generate_function(node)
41
+ when AST::Case
42
+ generate_case(node)
43
+ when AST::Subshell
44
+ generate_subshell(node)
45
+ when AST::Heredoc
46
+ generate_heredoc(node)
47
+ when AST::Herestring
48
+ generate_herestring(node)
49
+ when AST::Coproc
50
+ generate_coproc(node)
51
+ when AST::Time
52
+ generate_time(node)
53
+ when AST::ConditionalExpr
54
+ generate_conditional_expr(node)
55
+ when AST::ArithmeticCommand
56
+ generate_arithmetic_command(node)
57
+ when AST::ArrayAssign
58
+ generate_array_assign(node)
59
+ when AST::RubyCondition
60
+ generate_ruby_condition(node)
61
+ when AST::LazyLoad
62
+ generate_lazy_load(node)
63
+ else
64
+ raise "Unknown AST node: #{node.class}"
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def generate_command(node)
71
+ # Handle Ruby's p method specially - prints inspect result
72
+ # Wrap in __subshell to support redirects
73
+ if node.name == 'p' && !node.args.empty?
74
+ args = node.args.map { |a| generate_arg(a) }.join(', ')
75
+ return "__subshell { p(#{args}) }"
76
+ end
77
+
78
+ args = node.args.map { |a| generate_arg(a) }.join(', ')
79
+ name = generate_string_arg(node.name)
80
+
81
+ # Generate prefix environment variables hash
82
+ env_code = if node.env.empty?
83
+ nil
84
+ else
85
+ pairs = node.env.map do |assignment|
86
+ var, val = assignment.split('=', 2)
87
+ val ||= ''
88
+ "#{var.inspect} => #{generate_string_arg(val)}"
89
+ end
90
+ "{#{pairs.join(', ')}}"
91
+ end
92
+
93
+ cmd = if args.empty? && env_code.nil?
94
+ "__cmd(#{name})"
95
+ elsif args.empty?
96
+ "__cmd(#{name}, __prefix_env: #{env_code})"
97
+ elsif env_code.nil?
98
+ # Flatten args in case of glob expansion
99
+ "__cmd(#{name}, *[#{args}].flatten)"
100
+ else
101
+ "__cmd(#{name}, *[#{args}].flatten, __prefix_env: #{env_code})"
102
+ end
103
+
104
+ # Append block if present
105
+ if node.block
106
+ cmd = "#{cmd} #{node.block}"
107
+ end
108
+
109
+ cmd
110
+ end
111
+
112
+ def generate_arg(arg)
113
+ case arg
114
+ when String
115
+ # Special case: $@ or "$@" as standalone arg should expand to array
116
+ # so it becomes nothing when empty (not an empty string argument)
117
+ if arg == '$@' || arg == '"$@"'
118
+ return 'positional_params'
119
+ end
120
+ # Special case: unquoted $varname as standalone arg
121
+ # In bash, unquoted empty variable expansion is removed (word splitting)
122
+ # so $empty_var becomes nothing, not an empty string argument
123
+ if arg =~ /\A\$([a-zA-Z_][a-zA-Z0-9_]*)\z/
124
+ var_name = $1
125
+ return "__fetch_var_for_arg_unquoted(#{var_name.inspect})"
126
+ end
127
+ # Special case: quoted "$varname" as standalone arg
128
+ # In bash, quoted empty variable expansion is preserved as empty string
129
+ # so "$empty_var" becomes "" (one empty string argument)
130
+ if arg =~ /\A"\$([a-zA-Z_][a-zA-Z0-9_]*)"\z/
131
+ var_name = $1
132
+ return "__fetch_var_for_arg(#{var_name.inspect})"
133
+ end
134
+ generate_string_arg_with_glob(arg)
135
+ when AST::ArrayLiteral
136
+ arg.value # Already valid Ruby: [1, 2, 3]
137
+ when AST::RegexpLiteral
138
+ arg.value # Already valid Ruby: /pattern/
139
+ when AST::ProcessSubstitution
140
+ generate_process_substitution(arg)
141
+ else
142
+ arg.inspect
143
+ end
144
+ end
145
+
146
+ def generate_process_substitution(node)
147
+ direction = node.direction == :in ? ':in' : ':out'
148
+ "__proc_sub(#{node.command.inspect}, #{direction})"
149
+ end
150
+
151
+ def has_glob_chars?(str)
152
+ # Check for unquoted glob characters: *, ?, [...]
153
+ str.match?(/[*?\[]/)
154
+ end
155
+
156
+ def has_brace_expansion?(str)
157
+ # Check for brace expansion patterns: {a,b} or {1..5}
158
+ # Must have matching braces with either comma or ..
159
+ # But NOT ${...} which is parameter expansion
160
+ return false unless str.include?('{') && str.include?('}')
161
+
162
+ # Check for brace expansion, but exclude ${...} parameter expansion
163
+ # ${var,} or ${var,,} are case modification, not brace expansion
164
+ str.match?(/(?<!\$)\{[^}]*(?:,|\.\.)[^}]*\}/)
165
+ end
166
+
167
+ def generate_string_arg_with_glob(str)
168
+ # $'...' ANSI-C quoting: process escape sequences
169
+ if str.start_with?("$'") && str.end_with?("'")
170
+ return "process_escape_sequences(#{str[2...-1].inspect})"
171
+ end
172
+
173
+ # Single-quoted strings: no expansion at all
174
+ if str.start_with?("'") && str.end_with?("'")
175
+ return str[1...-1].inspect
176
+ end
177
+
178
+ # Double-quoted strings: variable expansion but no glob/brace
179
+ if str.start_with?('"') && str.end_with?('"')
180
+ inner = str[1...-1]
181
+ return generate_interpolated_string(inner)
182
+ end
183
+
184
+ # Check for brace expansion (happens before glob)
185
+ if has_brace_expansion?(str)
186
+ # Brace expansion returns an array, each element may need glob expansion
187
+ if has_glob_chars?(str)
188
+ # Both brace and glob: expand braces, then glob each result
189
+ return "__brace(#{str.inspect}).flat_map { |x| __glob(x) }"
190
+ else
191
+ return "__brace(#{str.inspect})"
192
+ end
193
+ end
194
+
195
+ # Check for glob characters (no brace)
196
+ if has_glob_chars?(str)
197
+ # If it also has variables, expand variables first then glob
198
+ if str.include?('$')
199
+ return "__glob(#{generate_interpolated_string(str)})"
200
+ else
201
+ return "__glob(#{str.inspect})"
202
+ end
203
+ end
204
+
205
+ # Check for abbreviated path (contains / but not a glob)
206
+ # Route through __glob which handles abbreviated path expansion
207
+ if str.include?('/') && !str.start_with?('/') && !str.include?('$')
208
+ return "__glob(#{str.inspect})"
209
+ end
210
+
211
+ # Check for VAR="value" or VAR='value' patterns with simple literal values
212
+ # Route through __glob which handles quote stripping for these
213
+ # Exclude values with $ (variable expansion) which need different handling
214
+ if str =~ /\A[a-zA-Z_][a-zA-Z0-9_]*=["'][^$]*["']\z/
215
+ return "__glob(#{str.inspect})"
216
+ end
217
+
218
+ # No glob or brace chars - use normal string arg generation
219
+ generate_string_arg(str)
220
+ end
221
+
222
+ def generate_string_arg(str)
223
+ # $'...' ANSI-C quoting: process escape sequences
224
+ if str.start_with?("$'") && str.end_with?("'")
225
+ return "process_escape_sequences(#{str[2...-1].inspect})"
226
+ end
227
+
228
+ # Single-quoted strings: no expansion, strip quotes
229
+ if str.start_with?("'") && str.end_with?("'")
230
+ return str[1...-1].inspect
231
+ end
232
+
233
+ # Double-quoted strings: strip quotes, expand variables
234
+ if str.start_with?('"') && str.end_with?('"')
235
+ inner = str[1...-1]
236
+ return generate_interpolated_string(inner)
237
+ end
238
+
239
+ # Check for special variables as standalone first
240
+ special = generate_special_variable(str)
241
+ return special if special
242
+
243
+ # Unquoted: expand variables
244
+ # If it's just a simple variable (not special), return the expression directly
245
+ if str =~ /\A\$([a-zA-Z_][a-zA-Z0-9_]*)\z/
246
+ return "__fetch_var(#{$1.inspect})"
247
+ end
248
+
249
+ # Check if string contains any variables or backtick substitution
250
+ if str.include?('$') || str.include?('`')
251
+ generate_interpolated_string(str)
252
+ else
253
+ str.inspect
254
+ end
255
+ end
256
+
257
+ def generate_special_variable(str)
258
+ case str
259
+ when '$?'
260
+ 'last_status.to_s'
261
+ when '$$'
262
+ 'Process.pid.to_s'
263
+ when '$!'
264
+ '(last_bg_pid ? last_bg_pid.to_s : "")'
265
+ when '$0'
266
+ '((a0 = get_var("RUBISH_ARGV0")) && !a0.empty? ? a0 : script_name)'
267
+ when /\A\$([1-9])\z/
268
+ "(positional_params[#{$1.to_i - 1}] || '')"
269
+ when '$#'
270
+ 'positional_params.length.to_s'
271
+ when '$@'
272
+ 'positional_params.join(" ")'
273
+ when '$*'
274
+ 'join_by_ifs(positional_params)'
275
+ else
276
+ nil
277
+ end
278
+ end
279
+
280
+ def generate_interpolated_string(str)
281
+ # Build a Ruby string with interpolation for variables
282
+ result = +'"'
283
+ i = 0
284
+
285
+ while i < str.length
286
+ char = str[i]
287
+
288
+ if char == '\\'
289
+ # Escape backslash for Ruby string
290
+ result << '\\\\'
291
+ i += 1
292
+ elsif char == '`'
293
+ # Backtick command substitution
294
+ cmd_expr, consumed = parse_backtick_substitution(str, i)
295
+ if cmd_expr
296
+ result << '#{' << cmd_expr << '}'
297
+ i += consumed
298
+ else
299
+ result << '`'
300
+ i += 1
301
+ end
302
+ elsif char == '$'
303
+ # Variable expansion
304
+ var_expr, consumed = parse_variable(str, i)
305
+ if var_expr
306
+ result << '#{' << var_expr << '}'
307
+ i += consumed
308
+ else
309
+ result << '$'
310
+ i += 1
311
+ end
312
+ elsif char == '"'
313
+ # Quote removal: skip double quotes (they're used for grouping, not literal)
314
+ i += 1
315
+ else
316
+ result << char
317
+ i += 1
318
+ end
319
+ end
320
+
321
+ result << '"'
322
+ result
323
+ end
324
+
325
+ def parse_variable(str, pos)
326
+ return nil unless str[pos] == '$'
327
+
328
+ # Check for arithmetic expansion $((...))
329
+ if str[pos + 1] == '(' && str[pos + 2] == '('
330
+ depth = 2
331
+ j = pos + 3
332
+ while j < str.length && depth > 0
333
+ if str[j] == '('
334
+ depth += 1
335
+ elsif str[j] == ')'
336
+ depth -= 1
337
+ end
338
+ j += 1
339
+ end
340
+ if depth == 0
341
+ expr = str[pos + 3...j - 2]
342
+ return ["__arith(#{expr.inspect})", j - pos]
343
+ end
344
+ return nil # Unclosed, treat as literal
345
+ end
346
+
347
+ # Check for command substitution $(...)
348
+ if str[pos + 1] == '('
349
+ depth = 1
350
+ j = pos + 2
351
+ while j < str.length && depth > 0
352
+ if str[j] == '('
353
+ depth += 1
354
+ elsif str[j] == ')'
355
+ depth -= 1
356
+ end
357
+ j += 1
358
+ end
359
+ if depth == 0
360
+ cmd = str[pos + 2...j - 1]
361
+ return ["__run_subst(#{cmd.inspect})", j - pos]
362
+ end
363
+ return nil # Unclosed, treat as literal
364
+ end
365
+
366
+ # Check for $"..." locale translation string
367
+ if str[pos + 1] == '"'
368
+ j = pos + 2
369
+ content = +'' # Mutable string
370
+ while j < str.length && str[j] != '"'
371
+ if str[j] == '\\'
372
+ # Handle escape sequences
373
+ content << str[j, 2]
374
+ j += 2
375
+ else
376
+ content << str[j]
377
+ j += 1
378
+ end
379
+ end
380
+ if j < str.length && str[j] == '"'
381
+ # Process any variable expansions in the content first
382
+ if content.include?('$')
383
+ expanded = generate_interpolated_string(content)
384
+ return ["__translate(#{expanded})", j - pos + 1]
385
+ else
386
+ return ["__translate(#{content.inspect})", j - pos + 1]
387
+ end
388
+ end
389
+ return nil # Unclosed, treat as literal
390
+ end
391
+
392
+ # Check for special variables first
393
+ two_char = str[pos, 2]
394
+ case two_char
395
+ when '$?'
396
+ return ['last_status.to_s', 2]
397
+ when '$$'
398
+ return ['Process.pid.to_s', 2]
399
+ when '$!'
400
+ return ['(last_bg_pid ? last_bg_pid.to_s : "")', 2]
401
+ when '$0'
402
+ return ['((a0 = get_var("RUBISH_ARGV0")) && !a0.empty? ? a0 : script_name)', 2]
403
+ when '$#'
404
+ return ['positional_params.length.to_s', 2]
405
+ when '$@'
406
+ return ['positional_params.join(" ")', 2]
407
+ when '$*'
408
+ return ['join_by_ifs(positional_params)', 2]
409
+ when /\$[1-9]/
410
+ n = str[pos + 1].to_i
411
+ return ["(positional_params[#{n - 1}] || '')", 2]
412
+ end
413
+
414
+ # ${VAR} or ${VAR:operation} form
415
+ if str[pos + 1] == '{'
416
+ end_brace = find_matching_brace(str, pos + 1)
417
+ if end_brace
418
+ content = str[pos + 2...end_brace]
419
+ expr = parse_parameter_expansion(content)
420
+ return [expr, end_brace - pos + 1]
421
+ end
422
+ end
423
+
424
+ # $VAR form
425
+ if str[pos + 1] =~ /[a-zA-Z_]/
426
+ j = pos + 1
427
+ j += 1 while j < str.length && str[j] =~ /[a-zA-Z0-9_]/
428
+ var_name = str[pos + 1...j]
429
+ return ["__fetch_var(#{var_name.inspect})", j - pos]
430
+ end
431
+
432
+ nil
433
+ end
434
+
435
+ def parse_backtick_substitution(str, pos)
436
+ return nil unless str[pos] == '`'
437
+
438
+ # Find matching closing backtick
439
+ j = pos + 1
440
+ while j < str.length
441
+ if str[j] == '\\'
442
+ # Skip escaped character
443
+ j += 2
444
+ elsif str[j] == '`'
445
+ # Found closing backtick
446
+ cmd = str[pos + 1...j]
447
+ return ["__run_subst(#{cmd.inspect})", j - pos + 1]
448
+ else
449
+ j += 1
450
+ end
451
+ end
452
+
453
+ nil # Unclosed backtick
454
+ end
455
+
456
+ def generate_pipeline(node)
457
+ # Check if last command is 'each' or 'map' with a block - handle specially
458
+ # Also check for redirects wrapping each/map
459
+ last_cmd = node.commands.last
460
+ unwrapped_last = unwrap_redirect(last_cmd)
461
+
462
+ if unwrapped_last.is_a?(AST::Command) && unwrapped_last.name == 'each' && unwrapped_last.block
463
+ return generate_pipeline_with_each(node)
464
+ elsif unwrapped_last.is_a?(AST::Command) && unwrapped_last.name == 'map' && unwrapped_last.block
465
+ # map is just each with implicit echo - transform the block body
466
+ return generate_pipeline_with_each(node, implicit_echo: true)
467
+ elsif unwrapped_last.is_a?(AST::Command) && unwrapped_last.name == 'select' && unwrapped_last.block
468
+ # select filters lines where block condition is true
469
+ return generate_pipeline_with_each(node, filter: true)
470
+ elsif unwrapped_last.is_a?(AST::Command) && unwrapped_last.name == 'detect' && unwrapped_last.block
471
+ # detect finds the first line where block condition is true
472
+ return generate_pipeline_with_each(node, find_first: true)
473
+ elsif unwrapped_last.is_a?(AST::Command) && unwrapped_last.name == 'p' && unwrapped_last.args.empty?
474
+ # p prints each line with .inspect (Ruby-style debug output)
475
+ return generate_pipeline_with_each(node, inspect_output: true)
476
+ end
477
+
478
+ # Handle pipe_types for |& (pipe both stdout and stderr)
479
+ parts = []
480
+ node.commands.each_with_index do |cmd, idx|
481
+ element = generate_pipeline_element(cmd)
482
+
483
+ # Check if this is followed by |& (pipe_both)
484
+ if node.pipe_types && idx < node.pipe_types.length && node.pipe_types[idx] == :pipe_both
485
+ # |& means redirect stderr to stdout before piping
486
+ parts << "#{element}.redirect_err_to_out"
487
+ else
488
+ parts << element
489
+ end
490
+ end
491
+ parts.join(' | ')
492
+ end
493
+
494
+ def generate_pipeline_with_each(node, implicit_echo: false, filter: false, find_first: false, inspect_output: false)
495
+ # Generate code for: cmd1 | cmd2 | ... | each {|var| body}
496
+ # Also handles map (with implicit_echo: true), select (with filter: true), detect (with find_first: true),
497
+ # and p (with inspect_output: true)
498
+ # Extract the each/map/select command and the source pipeline
499
+ last_node = node.commands.last
500
+ redirect_info = extract_redirect_info(last_node)
501
+ each_cmd = unwrap_redirect(last_node)
502
+ source_commands = node.commands[0...-1]
503
+ source_pipe_types = node.pipe_types ? node.pipe_types[0...-1] : nil
504
+
505
+ # Generate source pipeline code
506
+ source_code = if source_commands.length == 1
507
+ generate(source_commands.first)
508
+ else
509
+ source_pipeline = AST::Pipeline.new(commands: source_commands, pipe_types: source_pipe_types)
510
+ generate_pipeline(source_pipeline)
511
+ end
512
+
513
+ # Parse the block to extract variable and body (not needed for p)
514
+ var_name, body = if inspect_output
515
+ ['line', nil] # p doesn't use a block
516
+ else
517
+ parse_each_block(each_cmd.block)
518
+ end
519
+
520
+ # Generate the each loop
521
+ parts = []
522
+ parts << '__loop_break = catch(:break_loop) do'
523
+ parts << "__each_loop(#{var_name.inspect}, -> { #{source_code} }, #{body.inspect}) do |__line|"
524
+ parts << '__loop_cont = catch(:continue_loop) do'
525
+ parts << "#{var_name} = __line"
526
+
527
+ if inspect_output
528
+ # p: print line with .inspect (Ruby-style debug output)
529
+ parts << 'p __line'
530
+ elsif find_first
531
+ # detect: evaluate body as Ruby, output first line where truthy and break
532
+ parts << "if (#{body})"
533
+ parts << ' puts __line'
534
+ parts << ' throw(:break_loop)'
535
+ parts << 'end'
536
+ elsif filter
537
+ # select: evaluate body as Ruby, output line if truthy
538
+ parts << "puts __line if (#{body})"
539
+ elsif implicit_echo
540
+ # map: evaluate body as Ruby, output result
541
+ parts << "puts(#{body})"
542
+ else
543
+ # each: evaluate body as Ruby
544
+ parts << body
545
+ end
546
+
547
+ parts << 'nil; end'
548
+ parts << 'throw(:continue_loop, __loop_cont - 1) if __loop_cont.is_a?(Integer) && __loop_cont > 1'
549
+ parts << 'next if __loop_cont'
550
+ parts << 'end'
551
+ parts << 'nil; end'
552
+ parts << 'throw(:break_loop, __loop_break - 1) if __loop_break.is_a?(Integer) && __loop_break > 1'
553
+ each_code = parts.join("\n")
554
+
555
+ # If there's a redirect, wrap in subshell
556
+ if redirect_info
557
+ operator, target = redirect_info
558
+ target_code = generate_string_arg(target)
559
+ redirect_method = case operator
560
+ when '>' then 'redirect_out'
561
+ when '>>' then 'redirect_append'
562
+ when '2>' then 'redirect_err'
563
+ else 'redirect_out'
564
+ end
565
+ "__subshell { #{each_code} }.#{redirect_method}(#{target_code})"
566
+ else
567
+ each_code
568
+ end
569
+ end
570
+
571
+ def parse_each_block(block)
572
+ # Parse block to extract variable and body
573
+ # Supports formats with explicit variable:
574
+ # {|x| body} - curly brace format
575
+ # do |x| body end - do/end format
576
+ # Or implicit variable (defaults to 'it', accessed as $it):
577
+ # { body } - curly brace without variable
578
+ # do body end - do/end without variable
579
+ if block =~ /\A\{\s*\|(\w+)\|\s*(.*)\s*\}\z/m
580
+ # Curly brace format with variable: {|x| body}
581
+ [$1, $2.strip]
582
+ elsif block =~ /\A\{\s*(.*)\s*\}\z/m
583
+ # Curly brace format without variable: { body } - use implicit 'it'
584
+ ['it', $1.strip]
585
+ elsif block =~ /\Ado\s+\|(\w+)\|\s*(.*)\s+end\z/m
586
+ # Do/end format with variable: do |x| body end
587
+ [$1, $2.strip]
588
+ elsif block =~ /\Ado\s+(.*)\s+end\z/m
589
+ # Do/end format without variable: do body end - use implicit 'it'
590
+ ['it', $1.strip]
591
+ else
592
+ ['it', block]
593
+ end
594
+ end
595
+
596
+ def unwrap_redirect(node)
597
+ # Unwrap Redirect nodes to get the underlying command
598
+ if node.is_a?(AST::Redirect)
599
+ node.command
600
+ else
601
+ node
602
+ end
603
+ end
604
+
605
+ def extract_redirect_info(node)
606
+ # Extract redirect info from a node (returns [operator, target] or nil)
607
+ if node.is_a?(AST::Redirect)
608
+ [node.operator, node.target]
609
+ else
610
+ nil
611
+ end
612
+ end
613
+
614
+ def generate_negation(node)
615
+ "__negate { #{generate(node.command)} }"
616
+ end
617
+
618
+ def generate_pipeline_element(node)
619
+ # Compound commands need to be wrapped in __subshell to work in pipelines
620
+ # because they don't return Command objects that implement the | operator
621
+ if pipeline_compound_command?(node)
622
+ "__subshell { #{generate(node)} }"
623
+ elsif node.is_a?(AST::Redirect) && pipeline_compound_command?(node.command)
624
+ # Redirect wrapping a compound command - wrap in subshell and apply redirect
625
+ target = generate_string_arg(node.target)
626
+ op_method = case node.operator
627
+ when '>' then 'redirect_out'
628
+ when '>|' then 'redirect_clobber'
629
+ when '>>' then 'redirect_append'
630
+ when '<' then 'redirect_in'
631
+ when '2>' then 'redirect_err'
632
+ else 'redirect_out'
633
+ end
634
+ "__subshell { #{generate(node.command)} }.#{op_method}(#{target})"
635
+ else
636
+ generate(node)
637
+ end
638
+ end
639
+
640
+ def pipeline_compound_command?(node)
641
+ case node
642
+ when AST::For, AST::ArithFor, AST::While, AST::Until, AST::Select,
643
+ AST::If, AST::Unless, AST::Case, AST::Function
644
+ true
645
+ else
646
+ false
647
+ end
648
+ end
649
+
650
+ def generate_list(node)
651
+ # Each command in a list needs to run, not just the last one
652
+ node.commands.map { |c| "__run_cmd { #{generate(c)} }" }.join('; ')
653
+ end
654
+
655
+ def generate_redirect(node)
656
+ op_method = case node.operator
657
+ when '>' then 'redirect_out'
658
+ when '>|' then 'redirect_clobber'
659
+ when '>>' then 'redirect_append'
660
+ when '<' then 'redirect_in'
661
+ when '2>' then 'redirect_err'
662
+ when '>&' then 'dup_out'
663
+ when '<&' then 'dup_in'
664
+ end
665
+ target = generate_string_arg(node.target)
666
+
667
+ # For compound commands (loops, conditionals, etc.), use block-based redirection
668
+ if compound_command?(node.command)
669
+ "__with_redirect(#{node.operator.inspect}, #{target}) { #{generate(node.command)} }"
670
+ else
671
+ "#{generate(node.command)}.#{op_method}(#{target})"
672
+ end
673
+ end
674
+
675
+ def compound_command?(node)
676
+ # Compound commands that execute inline and can use __with_redirect
677
+ # Subshell is excluded because it creates a Subshell object that is run later
678
+ # and needs the redirect set on the object itself
679
+ case node
680
+ when AST::For, AST::ArithFor, AST::While, AST::Until, AST::Select,
681
+ AST::If, AST::Unless, AST::Case, AST::Function
682
+ true
683
+ else
684
+ false
685
+ end
686
+ end
687
+
688
+ def generate_varname_redirect(node)
689
+ # Generate code to allocate FD and redirect
690
+ varname = node.varname.inspect
691
+ operator = node.operator.inspect
692
+ target = generate_string_arg(node.target)
693
+ "__varname_redirect(#{varname}, #{operator}, #{target}) { #{generate(node.command)} }"
694
+ end
695
+
696
+ def generate_background(node)
697
+ "__background { #{generate(node.command)} }"
698
+ end
699
+
700
+ def generate_and(node)
701
+ "__and_cmd(-> { #{generate(node.left)} }, -> { #{generate(node.right)} })"
702
+ end
703
+
704
+ def generate_or(node)
705
+ "__or_cmd(-> { #{generate(node.left)} }, -> { #{generate(node.right)} })"
706
+ end
707
+
708
+ def generate_if(node)
709
+ parts = []
710
+
711
+ node.branches.each_with_index do |(condition, body), i|
712
+ keyword = i == 0 ? 'if' : 'elsif'
713
+ # RubyCondition returns boolean directly, no need for __condition wrapper
714
+ if condition.is_a?(AST::RubyCondition)
715
+ parts << "#{keyword} #{generate(condition)}"
716
+ else
717
+ parts << "#{keyword} __condition { #{generate(condition)} }"
718
+ end
719
+ # Use generate_loop_body to wrap single commands in __run_cmd
720
+ # This ensures commands are executed immediately within the if block
721
+ parts << generate_loop_body(body)
722
+ end
723
+
724
+ if node.else_body
725
+ parts << 'else'
726
+ parts << generate_loop_body(node.else_body)
727
+ end
728
+
729
+ parts << 'end'
730
+ parts.join("\n")
731
+ end
732
+
733
+ def generate_ruby_condition(node)
734
+ # Generate code that evaluates Ruby expression with shell variables bound as locals
735
+ "__ruby_condition(#{node.expression.inspect})"
736
+ end
737
+
738
+ def generate_unless(node)
739
+ parts = []
740
+
741
+ parts << "unless __condition { #{generate(node.condition)} }"
742
+ parts << generate_loop_body(node.body)
743
+
744
+ if node.else_body
745
+ parts << 'else'
746
+ parts << generate_loop_body(node.else_body)
747
+ end
748
+
749
+ parts << 'end'
750
+ parts.join("\n")
751
+ end
752
+
753
+ def generate_while(node)
754
+ parts = []
755
+ parts << '__loop_break = catch(:break_loop) do'
756
+ parts << "while __condition { #{generate(node.condition)} }"
757
+ parts << '__loop_cont = catch(:continue_loop) do'
758
+ parts << generate_loop_body(node.body)
759
+ parts << 'nil; end'
760
+ parts << 'throw(:continue_loop, __loop_cont - 1) if __loop_cont.is_a?(Integer) && __loop_cont > 1'
761
+ parts << 'next if __loop_cont'
762
+ parts << 'end'
763
+ parts << 'nil; end'
764
+ parts << 'throw(:break_loop, __loop_break - 1) if __loop_break.is_a?(Integer) && __loop_break > 1'
765
+ parts.join("\n")
766
+ end
767
+
768
+ def generate_until(node)
769
+ parts = []
770
+ parts << '__loop_break = catch(:break_loop) do'
771
+ parts << "until __condition { #{generate(node.condition)} }"
772
+ parts << '__loop_cont = catch(:continue_loop) do'
773
+ parts << generate_loop_body(node.body)
774
+ parts << 'nil; end'
775
+ parts << 'throw(:continue_loop, __loop_cont - 1) if __loop_cont.is_a?(Integer) && __loop_cont > 1'
776
+ parts << 'next if __loop_cont'
777
+ parts << 'end'
778
+ parts << 'nil; end'
779
+ parts << 'throw(:break_loop, __loop_break - 1) if __loop_break.is_a?(Integer) && __loop_break > 1'
780
+ parts.join("\n")
781
+ end
782
+
783
+ def generate_for(node)
784
+ items = node.items.map { |i| generate_for_item(i) }.join(', ')
785
+ parts = []
786
+ parts << '__loop_break = catch(:break_loop) do'
787
+ parts << "__for_loop(#{escape_string(node.variable)}, [#{items}].flatten) do"
788
+ parts << '__loop_cont = catch(:continue_loop) do'
789
+ parts << generate_loop_body(node.body)
790
+ parts << 'nil; end'
791
+ parts << 'throw(:continue_loop, __loop_cont - 1) if __loop_cont.is_a?(Integer) && __loop_cont > 1'
792
+ parts << 'next if __loop_cont'
793
+ parts << 'end'
794
+ parts << 'nil; end'
795
+ parts << 'throw(:break_loop, __loop_break - 1) if __loop_break.is_a?(Integer) && __loop_break > 1'
796
+ parts.join("\n")
797
+ end
798
+
799
+ def generate_arith_for(node)
800
+ # C-style for loop: for ((init; cond; update)); do body; done
801
+ parts = []
802
+ parts << '__loop_break = catch(:break_loop) do'
803
+ parts << "__arith_for_loop(#{node.init.inspect}, #{node.condition.inspect}, #{node.update.inspect}) do"
804
+ parts << '__loop_cont = catch(:continue_loop) do'
805
+ parts << generate_loop_body(node.body)
806
+ parts << 'nil; end'
807
+ parts << 'throw(:continue_loop, __loop_cont - 1) if __loop_cont.is_a?(Integer) && __loop_cont > 1'
808
+ parts << 'next if __loop_cont'
809
+ parts << 'end'
810
+ parts << 'nil; end'
811
+ parts << 'throw(:break_loop, __loop_break - 1) if __loop_break.is_a?(Integer) && __loop_break > 1'
812
+ parts.join("\n")
813
+ end
814
+
815
+ def generate_for_item(item)
816
+ # For loop items need word splitting on variable expansion
817
+ # $VAR with value "a b c" should become three items
818
+ # Also need glob/brace expansion for patterns like *.txt or {1..5}
819
+ if item =~ /\A\$([a-zA-Z_][a-zA-Z0-9_]*)\z/
820
+ # Simple variable - expand and split
821
+ "ENV.fetch(#{$1.inspect}, '').split"
822
+ elsif item =~ /\A\$\{([^}]+)\}\z/
823
+ # Braced variable - expand and split
824
+ "ENV.fetch(#{$1.inspect}, '').split"
825
+ elsif item.include?('$')
826
+ # Mixed content with variable - expand as string, then split
827
+ "#{generate_interpolated_string(item)}.split"
828
+ elsif has_brace_expansion?(item)
829
+ # Brace expansion - may also have glob
830
+ if has_glob_chars?(item)
831
+ "__brace(#{item.inspect}).flat_map { |x| __glob(x) }"
832
+ else
833
+ "__brace(#{item.inspect})"
834
+ end
835
+ elsif has_glob_chars?(item)
836
+ # Glob pattern - expand
837
+ "__glob(#{item.inspect})"
838
+ else
839
+ # Literal - no splitting needed
840
+ item.inspect
841
+ end
842
+ end
843
+
844
+ def generate_select(node)
845
+ items = node.items.map { |i| generate_for_item(i) }.join(', ')
846
+ parts = []
847
+ parts << '__loop_break = catch(:break_loop) do'
848
+ parts << "__select_loop(#{escape_string(node.variable)}, [#{items}].flatten) do"
849
+ parts << '__loop_cont = catch(:continue_loop) do'
850
+ parts << generate_loop_body(node.body)
851
+ parts << 'nil; end'
852
+ parts << 'throw(:continue_loop, __loop_cont - 1) if __loop_cont.is_a?(Integer) && __loop_cont > 1'
853
+ parts << 'next if __loop_cont'
854
+ parts << 'end'
855
+ parts << 'nil; end'
856
+ parts << 'throw(:break_loop, __loop_break - 1) if __loop_break.is_a?(Integer) && __loop_break > 1'
857
+ parts.join("\n")
858
+ end
859
+
860
+ def generate_loop_body(body)
861
+ # Lists already wrap each command in __run_cmd, but single commands don't
862
+ if body.is_a?(AST::List)
863
+ generate(body)
864
+ else
865
+ "__run_cmd { #{generate(body)} }"
866
+ end
867
+ end
868
+
869
+ def generate_function(node)
870
+ # Special handling for prompt functions with raw Ruby code
871
+ if node.body.is_a?(AST::RubyCode)
872
+ return "__define_function(#{node.name.inspect}, #{node.body.code.inspect}, nil) { nil }"
873
+ end
874
+
875
+ # Generate a function definition that stores a lambda
876
+ body_code = generate_loop_body(node.body)
877
+ # Also store shell source for declare -f
878
+ source_code = to_shell(node.body)
879
+ # Pass params array for Ruby-style def with arguments
880
+ params_code = node.params ? node.params.inspect : 'nil'
881
+ "__define_function(#{node.name.inspect}, #{source_code.inspect}, #{params_code}) { #{body_code} }"
882
+ end
883
+
884
+ def generate_case(node)
885
+ parts = []
886
+
887
+ # Check if word is a Ruby expression or a regular shell word
888
+ if node.word.is_a?(AST::RubyCondition)
889
+ # case { ruby_expr } in ... - evaluate Ruby expression to get value
890
+ parts << "__case_word = __ruby_condition(#{node.word.expression.inspect}).to_s"
891
+ else
892
+ word_expr = generate_string_arg(node.word)
893
+ parts << "__case_word = #{word_expr}"
894
+ end
895
+
896
+ node.branches.each_with_index do |(patterns, body), i|
897
+ keyword = i == 0 ? 'if' : 'elsif'
898
+ # Pattern matching
899
+ conditions = patterns.map { |p| generate_case_pattern_match(p) }
900
+ parts << "#{keyword} #{conditions.join(' || ')}"
901
+ parts << generate_loop_body(body)
902
+ end
903
+
904
+ parts << 'end'
905
+ parts.join("\n")
906
+ end
907
+
908
+ def generate_case_pattern_match(pattern)
909
+ # Convert shell pattern to fnmatch check
910
+ # Handle variable expansion in patterns
911
+ if pattern.include?('$')
912
+ pattern_expr = generate_interpolated_string(pattern)
913
+ "__case_match(#{pattern_expr}, __case_word)"
914
+ else
915
+ "__case_match(#{pattern.inspect}, __case_word)"
916
+ end
917
+ end
918
+
919
+ def generate_subshell(node)
920
+ body_code = generate_loop_body(node.body)
921
+ "__subshell { #{body_code} }"
922
+ end
923
+
924
+ def generate_heredoc(node)
925
+ cmd_code = generate(node.command)
926
+ # Content is set by REPL/source before execution
927
+ # At codegen time, we generate a call to __heredoc with placeholder
928
+ "__heredoc(#{node.delimiter.inspect}, #{node.expand}, #{node.strip_tabs}) { #{cmd_code} }"
929
+ end
930
+
931
+ def generate_herestring(node)
932
+ cmd_code = generate(node.command)
933
+ string_expr = generate_string_arg(node.string)
934
+ "__herestring(#{string_expr}) { #{cmd_code} }"
935
+ end
936
+
937
+ def generate_coproc(node)
938
+ cmd_code = generate(node.command)
939
+ "__coproc(#{node.name.inspect}) { #{cmd_code} }"
940
+ end
941
+
942
+ def generate_lazy_load(node)
943
+ # Check for eval "$(cmd)" pattern - the most common lazy_load use case
944
+ # We extract the command and run it thread-safely (without fork)
945
+ if node.body.is_a?(AST::Command) && node.body.name == 'eval' && node.body.args.length == 1
946
+ arg = node.body.args.first
947
+ # Match patterns like "$(cmd)" or '$(cmd)'
948
+ if arg =~ /\A["']\$\((.+)\)["']\z/
949
+ cmd = $1
950
+ return "__lazy_load_eval(#{cmd.inspect})"
951
+ end
952
+ end
953
+
954
+ # Fallback: generate the body code normally
955
+ # Note: this may hang if the body contains command substitutions that use fork
956
+ body_code = node.body ? generate(node.body) : 'nil'
957
+ "__lazy_load { #{body_code} }"
958
+ end
959
+
960
+ def generate_time(node)
961
+ if node.command
962
+ cmd_code = generate(node.command)
963
+ "__time(#{node.posix_format}) { #{cmd_code} }"
964
+ else
965
+ # time with no command just prints timing info (zeros)
966
+ "__time(#{node.posix_format}) { nil }"
967
+ end
968
+ end
969
+
970
+ def generate_conditional_expr(node)
971
+ # Convert tokens to expression parts for runtime evaluation
972
+ parts = node.expression.map do |token|
973
+ case token.type
974
+ when :WORD
975
+ generate_string_arg(token.value)
976
+ when :AND
977
+ '"&&"'
978
+ when :OR
979
+ '"||"'
980
+ when :LPAREN
981
+ '"("'
982
+ when :RPAREN
983
+ '")"'
984
+ else
985
+ token.value.inspect
986
+ end
987
+ end
988
+ "__cond_test([#{parts.join(', ')}])"
989
+ end
990
+
991
+ def generate_arithmetic_command(node)
992
+ # Generate code for arithmetic command (( expression ))
993
+ # The expression needs variable expansion but is evaluated as arithmetic
994
+ "__arithmetic_command(#{node.expression.inspect})"
995
+ end
996
+
997
+ def generate_array_assign(node)
998
+ # Generate code for array assignment: VAR=(a b c) or VAR+=(d e)
999
+ var_part = node.var
1000
+ elements = node.elements
1001
+
1002
+ # Generate element expressions with word splitting for command substitution
1003
+ elem_code = elements.map { |e| generate_array_element(e) }.join(', ')
1004
+
1005
+ # Call runtime method to handle the assignment
1006
+ "__array_assign(#{var_part.inspect}, [#{elem_code}].flatten)"
1007
+ end
1008
+
1009
+ # Generate code for an array element, with special handling for command substitution
1010
+ # In array context, $(cmd) should be word-split into multiple elements
1011
+ def generate_array_element(str)
1012
+ # Check if this element is purely a command substitution (handles nested $(...))
1013
+ if pure_command_substitution?(str)
1014
+ # Pure command substitution: $(cmd) - word-split the result
1015
+ cmd = str[2...-1]
1016
+ return "__run_subst(#{cmd.inspect}).split"
1017
+ end
1018
+
1019
+ # Check if element contains command substitution mixed with other text
1020
+ if str.include?('$(') || str.include?('`')
1021
+ # Mixed content - generate interpolated string but wrap in array for flatten
1022
+ return "[#{generate_string_arg(str)}]"
1023
+ end
1024
+
1025
+ # No command substitution - use normal string arg generation
1026
+ generate_string_arg(str)
1027
+ end
1028
+
1029
+ # Check if str is purely a $(...) command substitution, handling nested parens and quotes
1030
+ def pure_command_substitution?(str)
1031
+ return false unless str.start_with?('$(') && str.end_with?(')')
1032
+
1033
+ depth = 1
1034
+ i = 2
1035
+ while i < str.length
1036
+ c = str[i]
1037
+ if c == "'"
1038
+ # Skip single-quoted string
1039
+ i += 1
1040
+ i += 1 while i < str.length && str[i] != "'"
1041
+ elsif c == '"'
1042
+ # Skip double-quoted string (backslash escapes inside)
1043
+ i += 1
1044
+ while i < str.length && str[i] != '"'
1045
+ i += 1 if str[i] == '\\'
1046
+ i += 1
1047
+ end
1048
+ elsif c == '\\'
1049
+ i += 1 # skip escaped char
1050
+ elsif c == '('
1051
+ depth += 1
1052
+ elsif c == ')'
1053
+ depth -= 1
1054
+ return i == str.length - 1 if depth == 0
1055
+ end
1056
+ i += 1
1057
+ end
1058
+ false
1059
+ end
1060
+
1061
+ def escape_string(str)
1062
+ str.inspect
1063
+ end
1064
+
1065
+ def find_matching_brace(str, open_pos)
1066
+ # Find matching } for { at open_pos, handling nested braces
1067
+ depth = 1
1068
+ i = open_pos + 1
1069
+ while i < str.length && depth > 0
1070
+ case str[i]
1071
+ when '{'
1072
+ depth += 1
1073
+ when '}'
1074
+ depth -= 1
1075
+ when '\\'
1076
+ i += 1 # Skip escaped character
1077
+ end
1078
+ i += 1
1079
+ end
1080
+ depth == 0 ? i - 1 : nil
1081
+ end
1082
+
1083
+ def parse_parameter_expansion(content)
1084
+ # Handle ${!arr[@]} or ${!arr[*]} - array keys/indices
1085
+ if content =~ /\A!([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]\z/
1086
+ var_name = $1
1087
+ return "__array_keys(#{var_name.inspect})"
1088
+ end
1089
+
1090
+ # Handle ${#arr[@]} or ${#arr[*]} - array length
1091
+ if content =~ /\A#([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]\z/
1092
+ var_name = $1
1093
+ return "__array_length(#{var_name.inspect})"
1094
+ end
1095
+
1096
+ # Handle ${arr[@]} or ${arr[*]} - all array elements
1097
+ if content =~ /\A([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]\z/
1098
+ var_name = $1
1099
+ mode = $2
1100
+ return "__array_all(#{var_name.inspect}, #{mode.inspect})"
1101
+ end
1102
+
1103
+ # Handle ${arr[n]} - array element access
1104
+ if content =~ /\A([a-zA-Z_][a-zA-Z0-9_]*)\[([^\]]+)\]\z/
1105
+ var_name = $1
1106
+ index = $2
1107
+ return "__array_element(#{var_name.inspect}, #{index.inspect})"
1108
+ end
1109
+
1110
+ # Handle ${!var} - indirect expansion
1111
+ if content =~ /\A!([a-zA-Z_][a-zA-Z0-9_]*)\z/
1112
+ var_name = $1
1113
+ return "__param_indirect(#{var_name.inspect})"
1114
+ end
1115
+
1116
+ # Handle ${#var} - length
1117
+ if content =~ /\A#([a-zA-Z_][a-zA-Z0-9_]*)\z/
1118
+ var_name = $1
1119
+ return "__param_length(#{var_name.inspect})"
1120
+ end
1121
+
1122
+ # Handle ${var:offset} and ${var:offset:length} - must check before other : operators
1123
+ if content =~ /\A([a-zA-Z_][a-zA-Z0-9_]*):(-?\d+)(?::(-?\d+))?\z/
1124
+ var_name = $1
1125
+ offset = $2
1126
+ length = $3
1127
+ if length
1128
+ return "__param_substring(#{var_name.inspect}, #{offset}, #{length})"
1129
+ else
1130
+ return "__param_substring(#{var_name.inspect}, #{offset}, nil)"
1131
+ end
1132
+ end
1133
+
1134
+ # Handle ${var//pattern/replacement} and ${var/pattern/replacement}
1135
+ if content =~ /\A([a-zA-Z_][a-zA-Z0-9_]*)(\/\/|\/)((?:[^\/]|\\.)*)\/((?:[^\/]|\\.)*)?\z/
1136
+ var_name = $1
1137
+ operator = $2
1138
+ pattern = $3
1139
+ replacement = $4 || ''
1140
+ return "__param_replace(#{var_name.inspect}, #{operator.inspect}, #{pattern.inspect}, #{replacement.inspect})"
1141
+ end
1142
+
1143
+ # Handle ${var/pattern} - delete first match (no replacement)
1144
+ if content =~ /\A([a-zA-Z_][a-zA-Z0-9_]*)(\/\/|\/)((?:[^\/]|\\.)+)\z/
1145
+ var_name = $1
1146
+ operator = $2
1147
+ pattern = $3
1148
+ return "__param_replace(#{var_name.inspect}, #{operator.inspect}, #{pattern.inspect}, '')"
1149
+ end
1150
+
1151
+ # Handle ${var^^}, ${var^}, ${var,,}, ${var,} - case modification
1152
+ if content =~ /\A([a-zA-Z_][a-zA-Z0-9_]*)(\^\^|\^|,,|,)(?:([^}]*))?\z/
1153
+ var_name = $1
1154
+ operator = $2
1155
+ pattern = $3 || ''
1156
+ return "__param_case(#{var_name.inspect}, #{operator.inspect}, #{pattern.inspect})"
1157
+ end
1158
+
1159
+ # Handle ${var##pattern} and ${var%%pattern} - greedy versions first
1160
+ if content =~ /\A([a-zA-Z_][a-zA-Z0-9_]*)(##|%%)(.+)\z/
1161
+ var_name = $1
1162
+ operator = $2
1163
+ operand = $3
1164
+ return "__param_expand(#{var_name.inspect}, #{operator.inspect}, #{operand.inspect})"
1165
+ end
1166
+
1167
+ # Handle ${var#pattern} and ${var%pattern} - non-greedy versions
1168
+ if content =~ /\A([a-zA-Z_][a-zA-Z0-9_]*)(#|%)(.+)\z/
1169
+ var_name = $1
1170
+ operator = $2
1171
+ operand = $3
1172
+ return "__param_expand(#{var_name.inspect}, #{operator.inspect}, #{operand.inspect})"
1173
+ end
1174
+
1175
+ # Handle ${var:-default}, ${var:=default}, ${var:+value}, ${var:?message}
1176
+ # Also handles positional parameters: ${1:-default}, ${2:=value}, etc.
1177
+ if content =~ /\A([a-zA-Z_][a-zA-Z0-9_]*|\d+|[@*#?$!-])(:-|:=|:\+|:\?)(.*)?\z/
1178
+ var_name = $1
1179
+ operator = $2
1180
+ operand = $3 || ''
1181
+ return "__param_expand(#{var_name.inspect}, #{operator.inspect}, #{operand.inspect})"
1182
+ end
1183
+
1184
+ # Handle ${var-default}, ${var=default}, ${var+value}, ${var?message} (unset only, not null)
1185
+ # Also handles positional parameters: ${1-default}, ${2=value}, etc.
1186
+ if content =~ /\A([a-zA-Z_][a-zA-Z0-9_]*|\d+|[@*#?$!-])(-|=|\+|\?)(.*)?\z/
1187
+ var_name = $1
1188
+ operator = $2
1189
+ operand = $3 || ''
1190
+ return "__param_expand(#{var_name.inspect}, #{operator.inspect}, #{operand.inspect})"
1191
+ end
1192
+
1193
+ # Handle ${var@operator} - transformation operators (Q, E, P, A, a, U, u, L, K)
1194
+ if content =~ /\A([a-zA-Z_][a-zA-Z0-9_]*)@([QEPAaUuLK])\z/
1195
+ var_name = $1
1196
+ operator = $2
1197
+ return "__param_transform(#{var_name.inspect}, #{operator.inspect})"
1198
+ end
1199
+
1200
+ # Simple ${VAR}
1201
+ "__fetch_var(#{content.inspect})"
1202
+ end
1203
+
1204
+ # Convert AST back to shell source (for declare -f)
1205
+ def to_shell(node, indent = 0)
1206
+ prefix = ' ' * indent
1207
+ case node
1208
+ when AST::Command
1209
+ parts = [node.name] + node.args
1210
+ parts.join(' ')
1211
+ when AST::Pipeline
1212
+ node.commands.map { |c| to_shell(c) }.join(' | ')
1213
+ when AST::List
1214
+ node.commands.map { |c| to_shell(c, indent) }.join('; ')
1215
+ when AST::Redirect
1216
+ cmd = to_shell(node.command)
1217
+ "#{cmd} #{node.operator} #{node.target}"
1218
+ when AST::Background
1219
+ "#{to_shell(node.command)} &"
1220
+ when AST::And
1221
+ "#{to_shell(node.left)} && #{to_shell(node.right)}"
1222
+ when AST::Or
1223
+ "#{to_shell(node.left)} || #{to_shell(node.right)}"
1224
+ when AST::If
1225
+ parts = []
1226
+ node.branches.each_with_index do |(cond, body), i|
1227
+ keyword = i == 0 ? 'if' : 'elif'
1228
+ parts << "#{keyword} #{to_shell(cond)}; then"
1229
+ parts << " #{to_shell(body, indent + 1)}"
1230
+ end
1231
+ if node.else_body
1232
+ parts << 'else'
1233
+ parts << " #{to_shell(node.else_body, indent + 1)}"
1234
+ end
1235
+ parts << 'fi'
1236
+ parts.join("\n#{prefix}")
1237
+ when AST::Unless
1238
+ parts = ["unless #{to_shell(node.condition)}"]
1239
+ parts << " #{to_shell(node.body, indent + 1)}"
1240
+ if node.else_body
1241
+ parts << 'else'
1242
+ parts << " #{to_shell(node.else_body, indent + 1)}"
1243
+ end
1244
+ parts << 'end'
1245
+ parts.join("\n#{prefix}")
1246
+ when AST::While
1247
+ "while #{to_shell(node.condition)}; do\n#{prefix} #{to_shell(node.body, indent + 1)}\n#{prefix}done"
1248
+ when AST::Until
1249
+ "until #{to_shell(node.condition)}; do\n#{prefix} #{to_shell(node.body, indent + 1)}\n#{prefix}done"
1250
+ when AST::For
1251
+ items = node.items ? " in #{node.items.join(' ')}" : ''
1252
+ "for #{node.variable}#{items}; do\n#{prefix} #{to_shell(node.body, indent + 1)}\n#{prefix}done"
1253
+ when AST::Case
1254
+ parts = ["case #{node.word} in"]
1255
+ node.branches.each do |(patterns, body)|
1256
+ parts << " #{patterns.join('|')}) #{to_shell(body)} ;;"
1257
+ end
1258
+ parts << 'esac'
1259
+ parts.join("\n#{prefix}")
1260
+ when AST::Subshell
1261
+ "(#{to_shell(node.body)})"
1262
+ when AST::Function
1263
+ "#{node.name}() {\n#{prefix} #{to_shell(node.body, indent + 1)}\n#{prefix}}"
1264
+ when AST::ArrayAssign
1265
+ "#{node.var}(#{node.elements.join(' ')})"
1266
+ when NilClass
1267
+ ''
1268
+ else
1269
+ node.to_s
1270
+ end
1271
+ end
1272
+ end
1273
+ end