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,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
|