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.
- checksums.yaml +7 -0
- data/.dockerignore +23 -0
- data/Dockerfile +54 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +12 -0
- data/lib/rubish/arithmetic.rb +140 -0
- data/lib/rubish/ast.rb +168 -0
- data/lib/rubish/builtins/arithmetic.rb +129 -0
- data/lib/rubish/builtins/bind_readline.rb +834 -0
- data/lib/rubish/builtins/directory_stack.rb +182 -0
- data/lib/rubish/builtins/echo_printf.rb +510 -0
- data/lib/rubish/builtins/hash_directories.rb +260 -0
- data/lib/rubish/builtins/read.rb +299 -0
- data/lib/rubish/builtins/trap.rb +324 -0
- data/lib/rubish/codegen.rb +1273 -0
- data/lib/rubish/completion.rb +840 -0
- data/lib/rubish/completions/bash_helpers.rb +530 -0
- data/lib/rubish/completions/git.rb +431 -0
- data/lib/rubish/completions/help_parser.rb +453 -0
- data/lib/rubish/completions/ssh.rb +114 -0
- data/lib/rubish/config.rb +267 -0
- data/lib/rubish/data/builtin_help.rb +716 -0
- data/lib/rubish/data/completion_data.rb +53 -0
- data/lib/rubish/data/readline_config.rb +47 -0
- data/lib/rubish/data/shell_options.rb +251 -0
- data/lib/rubish/data_define.rb +65 -0
- data/lib/rubish/execution_context.rb +1124 -0
- data/lib/rubish/expansion.rb +988 -0
- data/lib/rubish/history.rb +663 -0
- data/lib/rubish/lazy_loader.rb +127 -0
- data/lib/rubish/lexer.rb +1194 -0
- data/lib/rubish/parser.rb +1167 -0
- data/lib/rubish/prompt.rb +766 -0
- data/lib/rubish/repl.rb +2267 -0
- data/lib/rubish/runtime/builtins.rb +7222 -0
- data/lib/rubish/runtime/command.rb +1153 -0
- data/lib/rubish/runtime/job.rb +153 -0
- data/lib/rubish/runtime.rb +1169 -0
- data/lib/rubish/shell_state.rb +241 -0
- data/lib/rubish/startup_profiler.rb +67 -0
- data/lib/rubish/version.rb +5 -0
- data/lib/rubish.rb +60 -0
- data/sig/rubish.rbs +4 -0
- 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
|