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,988 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubish
4
+ # String, variable, and parameter expansion for the shell REPL
5
+ # Handles variable expansion, command substitution, tilde expansion, parameter expansion, etc.
6
+ module Expansion
7
+ # Special associative arrays that use string keys (not registered with assoc_array?)
8
+ SPECIAL_ASSOC_ARRAYS = %w[RUBISH_ALIASES BASH_ALIASES RUBISH_CMDS BASH_CMDS].freeze
9
+
10
+ # Read-only shell variables that cannot be assigned
11
+ READONLY_SPECIAL_VARS = %w[
12
+ PPID UID EUID GROUPS HOSTNAME RUBISHPID BASHPID HISTCMD
13
+ EPOCHSECONDS EPOCHREALTIME SRANDOM RUBISH_MONOSECONDS BASH_MONOSECONDS
14
+ RUBISH_VERSION BASH_VERSION RUBISH_VERSINFO BASH_VERSINFO
15
+ OSTYPE HOSTTYPE MACHTYPE PIPESTATUS RUBISH_COMMAND BASH_COMMAND
16
+ FUNCNAME RUBISH_LINENO BASH_LINENO RUBISH_SOURCE BASH_SOURCE
17
+ RUBISH_ARGC BASH_ARGC RUBISH_ARGV BASH_ARGV RUBISH_SUBSHELL BASH_SUBSHELL
18
+ DIRSTACK COLUMNS LINES RUBISH_ALIASES BASH_ALIASES RUBISH_CMDS BASH_CMDS
19
+ COMP_CWORD COMP_LINE COMP_POINT COMP_TYPE COMP_KEY COMP_WORDS
20
+ RUBISH_EXECUTION_STRING BASH_EXECUTION_STRING RUBISH_REMATCH BASH_REMATCH
21
+ RUBISH BASH RUBISH_TRAPSIG BASH_TRAPSIG
22
+ ].freeze
23
+
24
+ def expand_args_for_builtin(args)
25
+ args.flat_map { |arg| expand_single_arg_with_brace_and_glob(arg) }
26
+ end
27
+
28
+ def bare_assignment?(str)
29
+ # Check if string is a bare variable assignment: VAR=value, arr=(a b c), or arr[0]=value
30
+ return false unless str.is_a?(String)
31
+ str =~ /\A[a-zA-Z_][a-zA-Z0-9_]*(\[[^\]]*\])?\+?=/
32
+ end
33
+
34
+ def extract_array_assignments(line)
35
+ # Check if line contains array assignment(s): arr=(a b c) or arr+=(d e)
36
+ # Returns array of full assignment strings, or nil if not array assignment
37
+ return nil unless line =~ /[a-zA-Z_][a-zA-Z0-9_]*\+?=\(/
38
+
39
+ assignments = []
40
+ remaining = line.strip
41
+
42
+ while remaining =~ /\A([a-zA-Z_][a-zA-Z0-9_]*\+?=\()/
43
+ prefix = $1
44
+ # Find matching closing paren
45
+ depth = 1
46
+ i = prefix.length
47
+ while i < remaining.length && depth > 0
48
+ case remaining[i]
49
+ when '(' then depth += 1
50
+ when ')' then depth -= 1
51
+ end
52
+ i += 1
53
+ end
54
+
55
+ return nil if depth != 0 # Unmatched parens
56
+
57
+ assignments << remaining[0...i]
58
+ remaining = remaining[i..].strip
59
+ end
60
+
61
+ # If there's remaining content that's not whitespace, this isn't a pure array assignment line
62
+ return nil unless remaining.empty?
63
+
64
+ assignments.empty? ? nil : assignments
65
+ end
66
+
67
+ def handle_bare_assignments(assignments)
68
+ assignments.each do |assignment|
69
+ if assignment =~ /\A([a-zA-Z_][a-zA-Z0-9_]*)\+?=\((.*)\)\z/m
70
+ handle_array_assignment($1, $2, assignment.include?('+='))
71
+ elsif assignment =~ /\A([a-zA-Z_][a-zA-Z0-9_]*)\[([^\]]+)\]=(.*)\z/
72
+ handle_array_element_assignment($1, $2, $3)
73
+ elsif assignment =~ /\A([a-zA-Z_][a-zA-Z0-9_]*)=(.*)\z/m
74
+ handle_scalar_assignment($1, $2)
75
+ end
76
+ end
77
+ end
78
+
79
+ def parse_assoc_array_elements(str)
80
+ # Parse associative array elements: [key1]=value1 [key2]=value2
81
+ pairs = {}
82
+ str.scan(/\[([^\]]+)\]=(\S+|'[^']*'|"[^"]*")/) do |key, value|
83
+ pairs[expand_string_content(key)] = expand_assignment_value(value)
84
+ end
85
+ pairs
86
+ end
87
+
88
+ def parse_array_elements(str)
89
+ # Parse array elements, respecting quotes and parentheses
90
+ elements = []
91
+ current = +''
92
+ in_single_quote = false
93
+ in_double_quote = false
94
+ paren_depth = 0
95
+
96
+ str.each_char do |char|
97
+ case char
98
+ when "'" then in_single_quote = !in_single_quote unless in_double_quote || paren_depth > 0; current << char
99
+ when '"' then in_double_quote = !in_double_quote unless in_single_quote || paren_depth > 0; current << char
100
+ when '(' then paren_depth += 1 unless in_single_quote || in_double_quote; current << char
101
+ when ')' then paren_depth -= 1 if paren_depth > 0 && !in_single_quote && !in_double_quote; current << char
102
+ when /\s/
103
+ if !in_single_quote && !in_double_quote && paren_depth == 0
104
+ elements.concat(expand_array_element(current)) unless current.empty?
105
+ current = +''
106
+ else
107
+ current << char
108
+ end
109
+ else
110
+ current << char
111
+ end
112
+ end
113
+
114
+ elements.concat(expand_array_element(current)) unless current.empty?
115
+ elements
116
+ end
117
+
118
+ # Expand an array element, with word splitting for command substitution
119
+ def expand_array_element(value)
120
+ return [''] if value.nil? || value.empty?
121
+
122
+ # Check if this is purely a command substitution: $(cmd) or `cmd`
123
+ if value =~ /\A\$\(.*\)\z/m || value =~ /\A`.*`\z/m
124
+ expanded = expand_string_content(value)
125
+ ifs = ENV['IFS'] || " \t\n"
126
+ return expanded.split(/[#{Regexp.escape(ifs)}]+/)
127
+ end
128
+
129
+ [expand_assignment_value(value)]
130
+ end
131
+
132
+ def expand_assignment_value(value)
133
+ return '' if value.nil? || value.empty?
134
+ expand_quoted_string(value) { expand_string_content(value) }
135
+ end
136
+
137
+ def expand_single_arg_with_brace_and_glob(arg)
138
+ return [arg] unless arg.is_a?(String)
139
+
140
+ # Handle quoted strings
141
+ result = expand_quoted_string(arg) { nil }
142
+ return [result] if result
143
+
144
+ # Brace expansion first (only if braceexpand option is enabled)
145
+ brace_expanded = if Builtins.set_option?('B') && arg.include?('{') && !arg.start_with?('$')
146
+ expand_braces(arg)
147
+ else
148
+ [arg]
149
+ end
150
+
151
+ # Then expand variables and globs on each result
152
+ brace_expanded.flat_map do |item|
153
+ expanded = expand_string_content(item)
154
+ next [] if expanded.empty?
155
+ expanded.match?(/[*?\[]/) ? __glob(expanded) : [expanded]
156
+ end
157
+ end
158
+
159
+ def expand_single_arg_with_glob(arg)
160
+ return [arg] unless arg.is_a?(String)
161
+
162
+ result = expand_quoted_string(arg) { nil }
163
+ return [result] if result
164
+
165
+ expanded = expand_string_content(arg)
166
+ return [] if expanded.empty?
167
+ expanded.match?(/[*?\[]/) ? __glob(expanded) : [expanded]
168
+ end
169
+
170
+ def expand_single_arg(arg)
171
+ return arg unless arg.is_a?(String)
172
+
173
+ result = expand_quoted_string(arg) { nil }
174
+ return result if result
175
+
176
+ expand_string_content(arg)
177
+ end
178
+
179
+ # Expand variables and command substitution in a string
180
+ # In double quotes, only \$, \`, \", \\, and \newline are special escape sequences
181
+ def expand_string_content(str)
182
+ result = +''
183
+ i = 0
184
+
185
+ while i < str.length
186
+ char = str[i]
187
+
188
+ if char == '\\'
189
+ # Escape sequence - only consume backslash for special characters
190
+ next_char = str[i + 1]
191
+ if next_char && '$`"\\'.include?(next_char)
192
+ result << next_char
193
+ i += 2
194
+ else
195
+ # Keep the backslash for other characters (like \C-a in bind)
196
+ result << char
197
+ i += 1
198
+ end
199
+ elsif char == '`'
200
+ expanded, consumed = expand_backtick_at(str, i)
201
+ result << expanded
202
+ i += consumed > 0 ? consumed : 1
203
+ elsif char == '$'
204
+ expanded, consumed = expand_variable_at(str, i)
205
+ result << (consumed > 0 ? expanded : char)
206
+ i += consumed > 0 ? consumed : 1
207
+ elsif char == '"'
208
+ i += 1 # Quote removal
209
+ else
210
+ result << char
211
+ i += 1
212
+ end
213
+ end
214
+
215
+ result
216
+ end
217
+
218
+ # Process $'...' (ANSI-C quoting) and $"..." (locale translation) in a string
219
+ # Used by extquote shopt option for parameter expansion operands
220
+ def expand_extquote(str)
221
+ result = +''
222
+ i = 0
223
+
224
+ while i < str.length
225
+ if str[i] == '$' && i + 1 < str.length
226
+ quote_char = str[i + 1]
227
+ if quote_char == "'" || quote_char == '"'
228
+ content, end_pos = extract_quoted_content(str, i + 2, quote_char)
229
+ if end_pos
230
+ if quote_char == "'"
231
+ result << Builtins.process_escape_sequences(content)
232
+ else
233
+ result << __translate(expand_string_content(content))
234
+ end
235
+ i = end_pos + 1
236
+ next
237
+ end
238
+ end
239
+ end
240
+ result << str[i]
241
+ i += 1
242
+ end
243
+
244
+ result
245
+ end
246
+
247
+ # Expand backtick command substitution at position
248
+ # Returns [expanded_output, characters_consumed]
249
+ def expand_backtick_at(str, pos)
250
+ return ['', 0] unless str[pos] == '`'
251
+
252
+ j = pos + 1
253
+ while j < str.length
254
+ if str[j] == '\\'
255
+ j += 2
256
+ elsif str[j] == '`'
257
+ return [__run_subst(str[pos + 1...j]), j - pos + 1]
258
+ else
259
+ j += 1
260
+ end
261
+ end
262
+
263
+ ['', 0]
264
+ end
265
+
266
+ # Expand variable/parameter at position in string
267
+ # Handles $VAR, ${VAR}, ${VAR:-default}, $(cmd), $((expr)), and special variables
268
+ # Returns [expanded_value, characters_consumed]
269
+ def expand_variable_at(str, pos)
270
+ return ['', 0] unless str[pos] == '$'
271
+
272
+ # Arithmetic expansion $((...))
273
+ if str[pos + 1] == '(' && str[pos + 2] == '('
274
+ end_pos = find_matching_parens(str, pos + 1, 2)
275
+ return [__arith(str[pos + 3...end_pos - 1]), end_pos + 1 - pos] if end_pos
276
+ return ['', 0]
277
+ end
278
+
279
+ # Command substitution $(...)
280
+ if str[pos + 1] == '('
281
+ end_pos = find_matching_parens(str, pos + 1, 1)
282
+ return [__run_subst(str[pos + 2...end_pos]), end_pos + 1 - pos] if end_pos
283
+ return ['', 0]
284
+ end
285
+
286
+ # Special two-character variables
287
+ two_char = str[pos, 2]
288
+ case two_char
289
+ when '$?' then return [@last_status.to_s, 2]
290
+ when '$$' then return [Process.pid.to_s, 2]
291
+ when '$!' then return [@last_bg_pid&.to_s || '', 2]
292
+ when '$0' then return [argv0, 2]
293
+ when '$#' then return [@positional_params.length.to_s, 2]
294
+ when '$@' then return [@positional_params.join(' '), 2]
295
+ when '$*' then return [Builtins.join_by_ifs(@positional_params), 2]
296
+ end
297
+
298
+ # Positional parameters $1-$9
299
+ if str[pos + 1] =~ /[1-9]/
300
+ return [@positional_params[str[pos + 1].to_i - 1] || '', 2]
301
+ end
302
+
303
+ # ${VAR} or ${VAR-default} form
304
+ if str[pos + 1] == '{'
305
+ end_brace = find_matching_brace(str, pos + 1)
306
+ return [expand_parameter_expansion(str[pos + 2...end_brace]), end_brace - pos + 1] if end_brace
307
+ end
308
+
309
+ # $VAR form
310
+ if str[pos + 1] =~ /[a-zA-Z_]/
311
+ j = pos + 1
312
+ j += 1 while j < str.length && str[j] =~ /[a-zA-Z0-9_]/
313
+ return [fetch_var_with_nounset(str[pos + 1...j]), j - pos]
314
+ end
315
+
316
+ ['', 0]
317
+ end
318
+
319
+ # Find matching } for { at open_pos, handling nested braces
320
+ def find_matching_brace(str, open_pos)
321
+ depth = 1
322
+ i = open_pos + 1
323
+ while i < str.length && depth > 0
324
+ case str[i]
325
+ when '{' then depth += 1
326
+ when '}' then depth -= 1
327
+ when '\\' then i += 1
328
+ end
329
+ i += 1
330
+ end
331
+ depth == 0 ? i - 1 : nil
332
+ end
333
+
334
+ def expand_parameter_expansion(content)
335
+ # ${#arr[@]} or ${#arr[*]} - array length
336
+ return __array_length($1) if content =~ /\A#([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]\z/
337
+
338
+ # ${!arr[@]} or ${!arr[*]} - array keys/indices
339
+ return __array_keys($1) if content =~ /\A!([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]\z/
340
+
341
+ # ${arr[@]} or ${arr[*]} - all array elements
342
+ return __array_all($1, $2) if content =~ /\A([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]\z/
343
+
344
+ # ${arr[n]} - array element access
345
+ return __array_element($1, $2) if content =~ /\A([a-zA-Z_][a-zA-Z0-9_]*)\[([^\]]+)\]\z/
346
+
347
+ # ${var:-default}, ${var-default}, etc.
348
+ # Also handles positional parameters: ${1:-default}, ${10:-default}, etc.
349
+ return __param_expand($1, $2, $3 || '') if content =~ /\A([a-zA-Z_][a-zA-Z0-9_]*|\d+)(:-|:=|:\+|:\?|-|=|\+|\?)(.*)?\z/
350
+
351
+ # ${#var} - length
352
+ return __param_length($1) if content =~ /\A#([a-zA-Z_][a-zA-Z0-9_]*)\z/
353
+
354
+ # Simple ${VAR}
355
+ fetch_var_with_nounset(content)
356
+ end
357
+
358
+ def fetch_var_with_nounset(var_name)
359
+ value = get_special_var_value(var_name)
360
+ return value if value
361
+
362
+ if Builtins.set_option?('u') && !Builtins.var_set?(var_name)
363
+ $stderr.puts Builtins.format_error('unbound variable', command: var_name)
364
+ raise NounsetError, "#{var_name}: unbound variable"
365
+ end
366
+ Builtins.get_var(var_name) || ''
367
+ end
368
+
369
+ # SECONDS - returns elapsed time since shell start (or last reset)
370
+ def seconds
371
+ (Time.now - @seconds_base).to_i
372
+ end
373
+
374
+ def reset_seconds(value = 0)
375
+ @seconds_base = Time.now - value.to_i
376
+ end
377
+
378
+ # RANDOM - returns random number 0-32767
379
+ def random
380
+ @random_generator.rand(32768)
381
+ end
382
+
383
+ def seed_random(seed)
384
+ @random_generator = Random.new(seed.to_i)
385
+ end
386
+
387
+ # COLUMNS - terminal width
388
+ def terminal_columns
389
+ IO.console&.winsize&.[](1) || ENV['COLUMNS']&.to_i || 80
390
+ end
391
+
392
+ # LINES - terminal height
393
+ def terminal_lines
394
+ IO.console&.winsize&.[](0) || ENV['LINES']&.to_i || 24
395
+ end
396
+
397
+ # checkwinsize: check window size after each command and update LINES/COLUMNS
398
+ def check_window_size
399
+ winsize = IO.console&.winsize
400
+ return unless winsize
401
+
402
+ lines, columns = winsize
403
+ ENV['LINES'] = lines.to_s if lines && lines > 0
404
+ ENV['COLUMNS'] = columns.to_s if columns && columns > 0
405
+ end
406
+
407
+ def extract_exit_status(result)
408
+ case result
409
+ when Command, Pipeline, Subshell, HeredocCommand
410
+ result.status&.exitstatus || 0
411
+ when ExitStatus
412
+ result.exitstatus
413
+ when Integer
414
+ result
415
+ else
416
+ 0
417
+ end
418
+ end
419
+
420
+ # Strip comments from line (text after unquoted #)
421
+ # Comments only start at # that's preceded by whitespace or at start of line
422
+ # Respects quoting and ${...} parameter expansion
423
+ def strip_comment(line)
424
+ result = +''
425
+ i = 0
426
+ in_single_quotes = false
427
+ in_double_quotes = false
428
+ brace_depth = 0
429
+
430
+ while i < line.length
431
+ char = line[i]
432
+
433
+ if char == '\\' && !in_single_quotes && i + 1 < line.length
434
+ result << char << line[i + 1]
435
+ i += 2
436
+ elsif char == "'" && !in_double_quotes && brace_depth == 0
437
+ in_single_quotes = !in_single_quotes
438
+ result << char
439
+ i += 1
440
+ elsif char == '"' && !in_single_quotes && brace_depth == 0
441
+ in_double_quotes = !in_double_quotes
442
+ result << char
443
+ i += 1
444
+ elsif char == '$' && line[i + 1] == '{' && !in_single_quotes
445
+ result << char << '{'
446
+ brace_depth += 1
447
+ i += 2
448
+ elsif char == '{' && brace_depth > 0
449
+ brace_depth += 1
450
+ result << char
451
+ i += 1
452
+ elsif char == '}' && brace_depth > 0
453
+ brace_depth -= 1
454
+ result << char
455
+ i += 1
456
+ elsif char == '#' && !in_single_quotes && !in_double_quotes && brace_depth == 0
457
+ prev_char = i > 0 ? result[-1] : nil
458
+ break if prev_char.nil? || prev_char =~ /\s/
459
+ result << char
460
+ i += 1
461
+ else
462
+ result << char
463
+ i += 1
464
+ end
465
+ end
466
+
467
+ result.rstrip
468
+ end
469
+
470
+ # Expand ~ and ~user (but not inside single quotes)
471
+ # Also handles ~+ (PWD), ~- (OLDPWD), and named directories
472
+ def expand_tilde(line)
473
+ result = +''
474
+ i = 0
475
+ in_single_quotes = false
476
+ in_double_quotes = false
477
+
478
+ while i < line.length
479
+ char = line[i]
480
+
481
+ if char == "'" && !in_double_quotes
482
+ in_single_quotes = !in_single_quotes
483
+ result << char
484
+ i += 1
485
+ elsif char == '"' && !in_single_quotes
486
+ in_double_quotes = !in_double_quotes
487
+ result << char
488
+ i += 1
489
+ elsif char == '~' && !in_single_quotes
490
+ expanded, consumed = expand_tilde_at(line, i, result)
491
+ result << expanded
492
+ i += consumed
493
+ else
494
+ result << char
495
+ i += 1
496
+ end
497
+ end
498
+
499
+ result
500
+ end
501
+
502
+ # Alias for internal use - delegates to get_special_var_value
503
+ def get_special_var(var_name)
504
+ get_special_var_value(var_name)
505
+ end
506
+
507
+ # Returns RUBISH_VERSINFO array similar to BASH_VERSINFO
508
+ # [0] major, [1] minor, [2] patch, [3] extra, [4] release status, [5] machine type
509
+ def rubish_versinfo
510
+ parts = Rubish::VERSION.split('.')
511
+ [parts[0] || '0', parts[1] || '0', parts[2] || '0', '', 'release', RUBY_PLATFORM]
512
+ end
513
+
514
+ # Returns OS type from RUBY_PLATFORM (e.g., "darwin23", "linux-gnu")
515
+ def ostype
516
+ parts = RUBY_PLATFORM.split('-', 2)
517
+ parts[1] || RUBY_PLATFORM
518
+ end
519
+
520
+ # Returns host/machine type from RUBY_PLATFORM (e.g., "arm64", "x86_64")
521
+ def hosttype
522
+ RUBY_PLATFORM.split('-').first
523
+ end
524
+
525
+ # Returns the value from the system's monotonic clock in seconds
526
+ # The monotonic clock is not affected by system time changes
527
+ def monoseconds
528
+ defined?(Process::CLOCK_MONOTONIC) ? Process.clock_gettime(Process::CLOCK_MONOTONIC).to_i : Time.now.to_i
529
+ end
530
+
531
+ # Returns the same value as $0 (the shell or script name)
532
+ # RUBISH_ARGV0 overrides @script_name if set (even if empty)
533
+ def argv0
534
+ Builtins.var_set?('RUBISH_ARGV0') ? Builtins.get_var('RUBISH_ARGV0') : @script_name
535
+ end
536
+
537
+ # Returns the full pathname used to invoke rubish (like BASH in bash)
538
+ def rubish_path
539
+ @rubish_path ||= begin
540
+ if $PROGRAM_NAME && File.exist?($PROGRAM_NAME)
541
+ File.expand_path($PROGRAM_NAME)
542
+ else
543
+ bin_path = File.expand_path('../../bin/rubish', __dir__)
544
+ if File.exist?(bin_path)
545
+ bin_path
546
+ else
547
+ path_dirs = (ENV['PATH'] || '').split(':')
548
+ path_dirs.map { |d| File.join(d, 'rubish') }.find { |p| File.exist?(p) } || $PROGRAM_NAME || 'rubish'
549
+ end
550
+ end
551
+ end
552
+ end
553
+
554
+ # Remove suffix matching pattern from value
555
+ # For shortest (%), find rightmost match start position
556
+ # For longest (%%), find leftmost match start position
557
+ def remove_suffix(value, pattern, mode)
558
+ regex = pattern_to_regex(pattern, :full, mode)
559
+
560
+ if mode == :shortest
561
+ (value.length - 1).downto(0) do |i|
562
+ return value[0...i] if regex.match?(value[i..])
563
+ end
564
+ else
565
+ (0...value.length).each do |i|
566
+ return value[0...i] if regex.match?(value[i..])
567
+ end
568
+ end
569
+ value
570
+ end
571
+
572
+ # Convert shell glob pattern to regex
573
+ # * -> .* or .*? (depending on greedy mode)
574
+ # ? -> .
575
+ # [...] -> [...] (with [! converted to [^)
576
+ # position: :prefix, :suffix, :full, or :any for anchoring
577
+ def pattern_to_regex(pattern, position, greedy)
578
+ regex_str = +''
579
+ i = 0
580
+ while i < pattern.length
581
+ char = pattern[i]
582
+ case char
583
+ when '*' then regex_str << (greedy == :longest ? '.*' : '.*?')
584
+ when '?' then regex_str << '.'
585
+ when '['
586
+ j = i + 1
587
+ j += 1 if j < pattern.length && pattern[j] == '!'
588
+ j += 1 if j < pattern.length && pattern[j] == ']'
589
+ j += 1 while j < pattern.length && pattern[j] != ']'
590
+ if j < pattern.length
591
+ bracket = pattern[i..j].sub('[!', '[^')
592
+ regex_str << bracket
593
+ i = j
594
+ else
595
+ regex_str << Regexp.escape(char)
596
+ end
597
+ else
598
+ regex_str << Regexp.escape(char)
599
+ end
600
+ i += 1
601
+ end
602
+
603
+ case position
604
+ when :prefix then Regexp.new("\\A#{regex_str}")
605
+ when :suffix then Regexp.new("#{regex_str}\\z")
606
+ when :full then Regexp.new("\\A#{regex_str}\\z")
607
+ else Regexp.new(regex_str)
608
+ end
609
+ end
610
+
611
+ private
612
+
613
+ # Handle $'...' and '...' and "..." quoting
614
+ def expand_quoted_string(value)
615
+ if value.start_with?("$'") && value.end_with?("'")
616
+ Builtins.process_escape_sequences(value[2...-1])
617
+ elsif value.start_with?("'") && value.end_with?("'")
618
+ value[1...-1]
619
+ elsif value.start_with?('"') && value.end_with?('"')
620
+ expand_string_content(value[1...-1])
621
+ else
622
+ yield # Return nil or call the block for unquoted handling
623
+ end
624
+ end
625
+
626
+ # Handle array assignment: arr=(a b c) or arr+=(d e) or map=([k]=v ...)
627
+ def handle_array_assignment(var_name, elements_str, is_append)
628
+ if elements_str =~ /\A\s*\[/ || Builtins.assoc_array?(var_name)
629
+ pairs = parse_assoc_array_elements(elements_str)
630
+ if is_append
631
+ pairs.each { |k, v| Builtins.set_assoc_element(var_name, k, v) }
632
+ else
633
+ Builtins.set_assoc_array(var_name, pairs)
634
+ end
635
+ else
636
+ elements = parse_array_elements(elements_str)
637
+ if var_name == 'COMPREPLY'
638
+ if is_append
639
+ Builtins.compreply.concat(elements)
640
+ else
641
+ Builtins.compreply = elements.dup
642
+ end
643
+ Builtins.set_array('COMPREPLY', Builtins.compreply.dup)
644
+ elsif is_append
645
+ Builtins.array_append(var_name, elements)
646
+ else
647
+ Builtins.set_array(var_name, elements)
648
+ end
649
+ end
650
+ end
651
+
652
+ # Handle array element assignment: arr[0]=value or map[key]=value
653
+ def handle_array_element_assignment(var_name, key, value)
654
+ expanded_key = expand_string_content(key)
655
+ expanded_value = expand_assignment_value(value)
656
+
657
+ # array_expand_once (bash 5.2+): when disabled, subscripts may be expanded again
658
+ # assoc_expand_once (deprecated): same but only for associative arrays
659
+ expand_once = Builtins.shopt_enabled?('array_expand_once') ||
660
+ (Builtins.assoc_array?(var_name) && Builtins.shopt_enabled?('assoc_expand_once'))
661
+ if (Builtins.assoc_array?(var_name) || Builtins.indexed_array?(var_name)) && !expand_once
662
+ expanded_key = expand_string_content(expanded_key) if expanded_key.include?('$')
663
+ end
664
+
665
+ if Builtins.assoc_array?(var_name)
666
+ Builtins.set_assoc_element(var_name, expanded_key, expanded_value)
667
+ elsif var_name == 'COMPREPLY'
668
+ idx = expanded_key.to_i
669
+ Builtins.compreply << nil while Builtins.compreply.length <= idx
670
+ Builtins.compreply[idx] = expanded_value
671
+ Builtins.set_array('COMPREPLY', Builtins.compreply.dup)
672
+ else
673
+ Builtins.set_array_element(var_name, expanded_key, expanded_value)
674
+ end
675
+ end
676
+
677
+ # Handle scalar variable assignment: VAR=value
678
+ # Includes special handling for SECONDS, RANDOM, LINENO, READLINE_*, etc.
679
+ def handle_scalar_assignment(var_name, value)
680
+ # Restricted mode: cannot modify restricted variables
681
+ if Builtins.restricted_mode? && Builtins::RESTRICTED_VARIABLES.include?(var_name)
682
+ $stderr.puts "rubish: #{var_name}: readonly variable"
683
+ return
684
+ end
685
+
686
+ expanded_value = expand_assignment_value(value)
687
+
688
+ case var_name
689
+ when 'SECONDS' then reset_seconds(expanded_value.to_i)
690
+ when 'RANDOM' then seed_random(expanded_value.to_i)
691
+ when 'LINENO' then @lineno = expanded_value.to_i
692
+ when 'BASH_ARGV0'
693
+ unless @bash_argv0_unset
694
+ Builtins.set_var('RUBISH_ARGV0', expanded_value)
695
+ ENV['RUBISH_ARGV0'] = expanded_value
696
+ else
697
+ Builtins.set_var(var_name, expanded_value)
698
+ end
699
+ when 'BASH_COMPAT' then Builtins.set_bash_compat(expanded_value)
700
+ when 'READLINE_LINE' then Builtins.readline_line = expanded_value
701
+ when 'READLINE_POINT' then Builtins.readline_point = expanded_value.to_i
702
+ when 'READLINE_MARK' then Builtins.readline_mark = expanded_value.to_i
703
+ when *READONLY_SPECIAL_VARS
704
+ # Read-only, silently ignore
705
+ else
706
+ Builtins.set_var(var_name, expanded_value)
707
+ end
708
+
709
+ Builtins.export_var(var_name) if Builtins.set_option?('a')
710
+ end
711
+
712
+ def find_matching_parens(str, start_pos, initial_depth)
713
+ depth = initial_depth
714
+ j = start_pos + initial_depth # Skip the initial opening parens
715
+ while j < str.length && depth > 0
716
+ depth += 1 if str[j] == '('
717
+ depth -= 1 if str[j] == ')'
718
+ j += 1
719
+ end
720
+ depth == 0 ? j - 1 : nil
721
+ end
722
+
723
+ def extract_quoted_content(str, start_pos, quote_char)
724
+ j = start_pos
725
+ content = +''
726
+ while j < str.length && str[j] != quote_char
727
+ if str[j] == '\\' && j + 1 < str.length
728
+ content << str[j, 2]
729
+ j += 2
730
+ else
731
+ content << str[j]
732
+ j += 1
733
+ end
734
+ end
735
+ j < str.length && str[j] == quote_char ? [content, j] : [nil, nil]
736
+ end
737
+
738
+ def expand_tilde_at(line, i, result)
739
+ prev_char = i > 0 ? line[i - 1] : nil
740
+ next_char = i + 1 < line.length ? line[i + 1] : nil
741
+
742
+ is_regex_op = prev_char == '=' && (next_char.nil? || next_char =~ /[\s\]]/)
743
+ at_word_start = !is_regex_op && (prev_char.nil? || prev_char =~ /[\s"'=:]/)
744
+
745
+ return ['~', 1] unless at_word_start
746
+
747
+ case next_char
748
+ when '+'
749
+ return [ENV['PWD'] || Dir.pwd, 2] if line[i + 2].nil? || line[i + 2] =~ %r{[\s/]}
750
+ when '-'
751
+ if line[i + 2].nil? || line[i + 2] =~ %r{[\s/]}
752
+ return ENV['OLDPWD'] ? [ENV['OLDPWD'], 2] : ['~-', 2]
753
+ end
754
+ end
755
+
756
+ j = i + 1
757
+ j += 1 while j < line.length && line[j] =~ /[a-zA-Z0-9_-]/
758
+
759
+ if j == i + 1
760
+ [Dir.home, 1]
761
+ else
762
+ name = line[i + 1...j]
763
+ named_dir = Builtins.get_named_directory(name)
764
+ if named_dir
765
+ [named_dir, j - i]
766
+ else
767
+ begin
768
+ [Dir.home(name), j - i]
769
+ rescue ArgumentError
770
+ [line[i...j], j - i]
771
+ end
772
+ end
773
+ end
774
+ end
775
+
776
+ def get_special_var_value(var_name)
777
+ case var_name
778
+ when 'SECONDS' then seconds.to_s
779
+ when 'RANDOM' then random.to_s
780
+ when 'LINENO' then @lineno.to_s
781
+ when 'PPID' then Process.ppid.to_s
782
+ when 'UID' then Process.uid.to_s
783
+ when 'EUID' then Process.euid.to_s
784
+ when 'GROUPS' then (Process.groups.first || '').to_s
785
+ when 'HOSTNAME' then Socket.gethostname
786
+ when 'RUBISHPID', 'BASHPID' then Process.pid.to_s
787
+ when 'HISTCMD' then @command_number.to_s
788
+ when 'EPOCHSECONDS' then Time.now.to_i.to_s
789
+ when 'EPOCHREALTIME' then format('%.6f', Time.now.to_f)
790
+ when 'SRANDOM' then SecureRandom.random_number(2**32).to_s
791
+ when 'RUBISH_MONOSECONDS', 'BASH_MONOSECONDS' then monoseconds.to_s
792
+ when 'BASH_ARGV0' then @bash_argv0_unset ? nil : argv0
793
+ when 'RUBISH_VERSION', 'BASH_VERSION' then Rubish::VERSION
794
+ when 'OSTYPE' then ostype
795
+ when 'HOSTTYPE' then hosttype
796
+ when 'MACHTYPE' then RUBY_PLATFORM
797
+ when 'RUBISH_COMMAND', 'BASH_COMMAND' then @rubish_command
798
+ when 'RUBISH_SUBSHELL', 'BASH_SUBSHELL' then @subshell_level.to_s
799
+ when 'COLUMNS' then terminal_columns.to_s
800
+ when 'LINES' then terminal_lines.to_s
801
+ when 'COMP_LINE' then Builtins.comp_line
802
+ when 'COMP_POINT' then Builtins.comp_point.to_s
803
+ when 'COMP_CWORD' then Builtins.comp_cword.to_s
804
+ when 'COMP_TYPE' then Builtins.comp_type.to_s
805
+ when 'COMP_KEY' then Builtins.comp_key.to_s
806
+ when 'COMP_WORDBREAKS' then Builtins.comp_wordbreaks
807
+ when 'SHELLOPTS' then Builtins.shellopts
808
+ when 'RUBISHOPTS' then Builtins.rubishopts
809
+ when 'BASHOPTS' then Builtins.bashopts
810
+ when 'BASH_COMPAT' then Builtins.bash_compat
811
+ when 'RUBISH_EXECUTION_STRING', 'BASH_EXECUTION_STRING' then ENV['RUBISH_EXECUTION_STRING'] || ''
812
+ when 'RUBISH', 'BASH' then rubish_path
813
+ when 'RUBISH_TRAPSIG', 'BASH_TRAPSIG' then Builtins.current_state.current_trapsig || ''
814
+ when 'READLINE_LINE' then Builtins.readline_line
815
+ when 'READLINE_POINT' then Builtins.readline_point.to_s
816
+ when 'READLINE_MARK' then Builtins.readline_mark.to_s
817
+ end
818
+ end
819
+
820
+ # Get parameter expansion info for a variable
821
+ # Returns [value, is_set, is_null] tuple for use in parameter expansion operators
822
+ def get_param_expand_info(var_name)
823
+ # Check for special shell parameters first ($0-$9, $#, $@, $*, $?, $$, $!, $-)
824
+ case var_name
825
+ when /\A\d+\z/
826
+ n = var_name.to_i
827
+ if n == 0
828
+ value = argv0
829
+ [value, true, value.empty?]
830
+ else
831
+ value = @positional_params[n - 1]
832
+ [value || '', n <= @positional_params.length, value.nil? || value.empty?]
833
+ end
834
+ when '#' then [@positional_params.length.to_s, true, false]
835
+ when '@' then [@positional_params.join(' '), true, @positional_params.empty?]
836
+ when '*' then [Builtins.join_by_ifs(@positional_params), true, @positional_params.empty?]
837
+ when '?' then [@last_status.to_s, true, false]
838
+ when '$' then [Process.pid.to_s, true, false]
839
+ when '!'
840
+ value = @last_bg_pid&.to_s || ''
841
+ [value, !@last_bg_pid.nil?, @last_bg_pid.nil?]
842
+ when '-' then [Builtins.current_options, true, Builtins.current_options.empty?]
843
+ else
844
+ # Check special variables
845
+ special_value = get_special_var_value(var_name)
846
+ if special_value
847
+ is_null = special_value.respond_to?(:empty?) ? special_value.empty? : false
848
+ [special_value, true, is_null]
849
+ else
850
+ value = Builtins.get_var(var_name)
851
+ [value, Builtins.var_set?(var_name), value.nil? || value.empty?]
852
+ end
853
+ end
854
+ end
855
+
856
+ def assign_default(var_name, operand)
857
+ if Builtins.restricted_mode? && Builtins::RESTRICTED_VARIABLES.include?(var_name)
858
+ $stderr.puts "rubish: #{var_name}: readonly variable"
859
+ ''
860
+ else
861
+ Builtins.set_var(var_name, operand)
862
+ operand
863
+ end
864
+ end
865
+
866
+ # Build a proc for pattern replacement with & substitution
867
+ # When patsub_replacement is enabled, & in replacement is replaced with the matched text
868
+ # \& is a literal &
869
+ def build_replacement_proc(replacement)
870
+ return nil unless Builtins.shopt_enabled?('patsub_replacement') && replacement.include?('&')
871
+
872
+ proc do |match|
873
+ result = +''
874
+ i = 0
875
+ while i < replacement.length
876
+ if replacement[i] == '\\' && i + 1 < replacement.length && replacement[i + 1] == '&'
877
+ result << '&'
878
+ i += 2
879
+ elsif replacement[i] == '&'
880
+ result << match
881
+ i += 1
882
+ else
883
+ result << replacement[i]
884
+ i += 1
885
+ end
886
+ end
887
+ result
888
+ end
889
+ end
890
+
891
+ def apply_case_transform(value, pattern, method, scope)
892
+ if pattern.empty?
893
+ scope == :all ? value.send(method) : value[0].send(method) + value[1..]
894
+ else
895
+ regex = pattern_to_regex(pattern, :any, :longest)
896
+ if scope == :all
897
+ value.gsub(regex) { |m| m.send(method) }
898
+ else
899
+ value[0].match?(regex) ? value[0].send(method) + value[1..] : value
900
+ end
901
+ end
902
+ end
903
+
904
+ # Expand array index/key based on array type
905
+ # For associative arrays: expand as string (key lookup)
906
+ # For indexed arrays: evaluate as arithmetic expression (allows bare variable names like ${arr[COMP_CWORD]})
907
+ def expand_array_index(var_name, index)
908
+ if Builtins.assoc_array?(var_name) || SPECIAL_ASSOC_ARRAYS.include?(var_name)
909
+ expanded = expand_string_content(index)
910
+ # assoc_expand_once: when disabled, subscripts may be expanded again
911
+ unless Builtins.shopt_enabled?('assoc_expand_once')
912
+ expanded = expand_string_content(expanded) if expanded.include?('$')
913
+ end
914
+ expanded
915
+ else
916
+ # Indexed array: evaluate subscript as arithmetic expression
917
+ begin
918
+ expanded = eval_arithmetic_expr(index).to_s
919
+ rescue
920
+ expanded = expand_string_content(index)
921
+ end
922
+ # array_expand_once (bash 5.2+): when disabled, subscripts may be expanded again
923
+ unless Builtins.shopt_enabled?('array_expand_once')
924
+ expanded = expand_string_content(expanded) if expanded.include?('$')
925
+ end
926
+ expanded
927
+ end
928
+ end
929
+
930
+ def safe_eval_index(expanded_index)
931
+ eval(expanded_index).to_i
932
+ rescue
933
+ expanded_index.to_i
934
+ end
935
+
936
+ # Get values for special shell arrays (GROUPS, PIPESTATUS, FUNCNAME, etc.)
937
+ # Returns Array for indexed arrays, :assoc for special associative arrays, nil otherwise
938
+ def get_special_array_values(var_name)
939
+ case var_name
940
+ when 'GROUPS' then Process.groups
941
+ when 'RUBISH_VERSINFO', 'BASH_VERSINFO' then rubish_versinfo
942
+ when 'PIPESTATUS' then @pipestatus
943
+ when 'FUNCNAME' then @funcname_stack
944
+ when 'RUBISH_LINENO', 'BASH_LINENO' then @rubish_lineno_stack
945
+ when 'RUBISH_SOURCE', 'BASH_SOURCE' then @rubish_source_stack
946
+ when 'RUBISH_ARGC', 'BASH_ARGC' then @rubish_argc_stack
947
+ when 'RUBISH_ARGV', 'BASH_ARGV' then @rubish_argv_stack
948
+ when 'DIRSTACK' then [Dir.pwd] + Builtins.current_state.dir_stack
949
+ when 'COMP_WORDS' then Builtins.comp_words
950
+ when 'COMPREPLY' then Builtins.compreply
951
+ when 'RUBISH_REMATCH', 'BASH_REMATCH' then @state.arrays['RUBISH_REMATCH'] || []
952
+ when 'RUBISH_ALIASES', 'BASH_ALIASES', 'RUBISH_CMDS', 'BASH_CMDS' then :assoc
953
+ end
954
+ end
955
+
956
+ def get_special_assoc_value(var_name, key)
957
+ case var_name
958
+ when 'RUBISH_ALIASES', 'BASH_ALIASES' then (Builtins.current_state.aliases[key] || '').to_s
959
+ when 'RUBISH_CMDS', 'BASH_CMDS' then (Builtins.current_state.command_hash[key] || '').to_s
960
+ else ''
961
+ end
962
+ end
963
+
964
+ def get_special_assoc_all_values(var_name)
965
+ case var_name
966
+ when 'RUBISH_ALIASES', 'BASH_ALIASES' then Builtins.current_state.aliases.values
967
+ when 'RUBISH_CMDS', 'BASH_CMDS' then Builtins.current_state.command_hash.values
968
+ else []
969
+ end
970
+ end
971
+
972
+ def get_special_assoc_length(var_name)
973
+ case var_name
974
+ when 'RUBISH_ALIASES', 'BASH_ALIASES' then Builtins.current_state.aliases.length
975
+ when 'RUBISH_CMDS', 'BASH_CMDS' then Builtins.current_state.command_hash.length
976
+ else 0
977
+ end
978
+ end
979
+
980
+ def get_special_assoc_keys(var_name)
981
+ case var_name
982
+ when 'RUBISH_ALIASES', 'BASH_ALIASES' then Builtins.current_state.aliases.keys
983
+ when 'RUBISH_CMDS', 'BASH_CMDS' then Builtins.current_state.command_hash.keys
984
+ else []
985
+ end
986
+ end
987
+ end
988
+ end