srsh 0.7.1 → 1.0.0
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 +4 -4
- data/LICENSE +18 -14
- data/README.md +351 -134
- data/bin/srsh +53 -1473
- data/docs/assets/slut.txt +4 -0
- data/docs/assets/srsh-mark.svg +12 -0
- data/docs/css/style.css +696 -0
- data/docs/index.html +703 -0
- data/docs/js/app.js +203 -0
- data/examples/bridge.rsh +8 -0
- data/examples/calculator.rsh +253 -0
- data/examples/defer.rsh +14 -0
- data/examples/hot.rsh +14 -0
- data/examples/meta.rsh +20 -0
- data/examples/modules/text.rsh +6 -0
- data/examples/modules.rsh +6 -0
- data/examples/paste.rsh +15 -0
- data/examples/plugin.rb +8 -0
- data/examples/power.rsh +65 -0
- data/examples/tour.rsh +38 -0
- data/ext/srsh_native/extconf.rb +3 -0
- data/ext/srsh_native/srsh_native.c +48 -0
- data/language-docs/LANGUAGE.md +670 -0
- data/language-docs/MIGRATION.md +44 -0
- data/language-docs/SECURITY.md +44 -0
- data/lib/srsh/app.rb +261 -0
- data/lib/srsh/builtins.rb +492 -0
- data/lib/srsh/editor.rb +530 -0
- data/lib/srsh/errors.rb +23 -0
- data/lib/srsh/history.rb +74 -0
- data/lib/srsh/language/evaluator.rb +1175 -0
- data/lib/srsh/language/lexer.rb +316 -0
- data/lib/srsh/language/parser.rb +997 -0
- data/lib/srsh/language/token.rb +5 -0
- data/lib/srsh/language/values.rb +392 -0
- data/lib/srsh/paths.rb +29 -0
- data/lib/srsh/plugins.rb +59 -0
- data/lib/srsh/process_identity.rb +38 -0
- data/lib/srsh/security.rb +38 -0
- data/lib/srsh/shell/executor.rb +1182 -0
- data/lib/srsh/shell/job.rb +101 -0
- data/lib/srsh/shell/lexer.rb +114 -0
- data/lib/srsh/shell/terminal.rb +26 -0
- data/lib/srsh/state.rb +136 -0
- data/lib/srsh/theme.rb +108 -0
- data/lib/srsh/version.rb +1 -2
- data/lib/srsh.rb +15 -0
- metadata +59 -11
|
@@ -0,0 +1,1182 @@
|
|
|
1
|
+
require 'shellwords'
|
|
2
|
+
require 'stringio'
|
|
3
|
+
require_relative '../errors'
|
|
4
|
+
require_relative '../language/parser'
|
|
5
|
+
require_relative '../language/evaluator'
|
|
6
|
+
require_relative 'lexer'
|
|
7
|
+
require_relative 'job'
|
|
8
|
+
require_relative 'terminal'
|
|
9
|
+
|
|
10
|
+
module Srsh
|
|
11
|
+
module Shell
|
|
12
|
+
Stage = Data.define(:words, :stdin_path, :stdout_path, :stdout_append, :stderr_path, :stderr_append)
|
|
13
|
+
Pipeline = Data.define(:stages, :background, :source)
|
|
14
|
+
|
|
15
|
+
class Executor
|
|
16
|
+
MAX_ALIAS_DEPTH = 32
|
|
17
|
+
MAX_SUBST_DEPTH = 16
|
|
18
|
+
MAX_SUBST_BYTES = 512 * 1024
|
|
19
|
+
MAX_FUNCTION_DEPTH = 128
|
|
20
|
+
|
|
21
|
+
attr_reader :evaluator
|
|
22
|
+
|
|
23
|
+
def initialize(app)
|
|
24
|
+
@app = app
|
|
25
|
+
@state = app.state
|
|
26
|
+
@evaluator = Language::Evaluator.new(@state, self)
|
|
27
|
+
@command_cache = {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def function?(name) = @state.functions.key?(name)
|
|
31
|
+
def prototype?(name) = @state.prototypes.key?(name)
|
|
32
|
+
|
|
33
|
+
def prototype_methods(name)
|
|
34
|
+
proto = @state.prototypes[name.to_s]
|
|
35
|
+
proto ? proto[:methods].keys.sort : []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def object_method?(object, name)
|
|
39
|
+
proto = @state.prototypes[object.proto_name]
|
|
40
|
+
proto && proto[:methods].key?(name.to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def execute_line(line, capture: false)
|
|
44
|
+
chunks = split_connectors(line)
|
|
45
|
+
status = @state.last_status
|
|
46
|
+
chunks.each do |connector, text|
|
|
47
|
+
run = connector == :seq || (connector == :and && status.zero?) || (connector == :or && !status.zero?)
|
|
48
|
+
next unless run
|
|
49
|
+
status = execute_pipeline(text, capture: capture)
|
|
50
|
+
@state.last_status = status
|
|
51
|
+
end
|
|
52
|
+
status
|
|
53
|
+
rescue ParseError => e
|
|
54
|
+
@app.err.puts @app.theme.paint("srsh: #{e.message}", :error, io: @app.err)
|
|
55
|
+
@state.last_status = 2
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def capture(command)
|
|
59
|
+
key = :"srsh_subst_depth_#{object_id}"
|
|
60
|
+
depth = Thread.current[key].to_i
|
|
61
|
+
raise RuntimeError, 'command substitution nesting too deep' if depth >= MAX_SUBST_DEPTH
|
|
62
|
+
Thread.current[key] = depth + 1
|
|
63
|
+
r, w = IO.pipe
|
|
64
|
+
pid = fork do
|
|
65
|
+
begin
|
|
66
|
+
r.close
|
|
67
|
+
STDOUT.reopen(w)
|
|
68
|
+
@app.out = STDOUT if @app.respond_to?(:out=)
|
|
69
|
+
status = execute_line(command, capture: true)
|
|
70
|
+
STDOUT.flush
|
|
71
|
+
exit!(status.to_i & 0xff)
|
|
72
|
+
rescue Exception => e # child boundary
|
|
73
|
+
STDERR.puts "srsh substitution: #{e.message}"
|
|
74
|
+
exit!(125)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
w.close
|
|
78
|
+
data = +''
|
|
79
|
+
while (chunk = r.read(16 * 1024))
|
|
80
|
+
break if chunk.empty?
|
|
81
|
+
remaining = MAX_SUBST_BYTES - data.bytesize
|
|
82
|
+
break if remaining <= 0
|
|
83
|
+
data << chunk.byteslice(0, remaining)
|
|
84
|
+
end
|
|
85
|
+
r.close
|
|
86
|
+
Process.wait(pid)
|
|
87
|
+
@state.last_status = $?.exitstatus || 1
|
|
88
|
+
data.sub(/\n+\z/, '')
|
|
89
|
+
ensure
|
|
90
|
+
Thread.current[key] = [Thread.current[key].to_i - 1, 0].max if defined?(key)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def call_function(name, args, call_seed: nil)
|
|
94
|
+
fn = @state.functions[name]
|
|
95
|
+
raise RuntimeError, "unknown function #{name}" unless fn
|
|
96
|
+
seed = (call_seed || {}).merge(captured_seed(fn))
|
|
97
|
+
if fn[:async]
|
|
98
|
+
# Async calls cross to a fresh OS thread. Plain data is copied so
|
|
99
|
+
# lexical capture cannot smuggle accidental shared mutation across.
|
|
100
|
+
async_seed = @evaluator.worker_snapshot(@state.locals_snapshot.merge(seed))
|
|
101
|
+
async_args = @evaluator.worker_snapshot(args)
|
|
102
|
+
return Language::TaskValue.new do
|
|
103
|
+
invoke_rsh_body(name, fn[:params], fn[:body], fn[:line], async_args, async_seed)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
invoke_rsh_body(name, fn[:params], fn[:body], fn[:line], args, seed)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def instantiate(name, args)
|
|
110
|
+
proto = @state.prototypes[name.to_s]
|
|
111
|
+
raise RuntimeError, "unknown prototype #{name}" unless proto
|
|
112
|
+
object = Language::ObjectValue.new(name)
|
|
113
|
+
with_call_depth('prototype construction') do
|
|
114
|
+
scope = captured_seed(proto).merge('$0' => name.to_s, 'self' => object)
|
|
115
|
+
args.each_with_index { |value, index| scope["$#{index + 1}"] = value }
|
|
116
|
+
@state.push_scope(scope)
|
|
117
|
+
begin
|
|
118
|
+
bind_params(proto[:params], args, proto[:line])
|
|
119
|
+
proto[:slots].each do |slot|
|
|
120
|
+
value = @evaluator.parse_eval(slot.expr, line: slot.number)
|
|
121
|
+
object.set(slot.name, value)
|
|
122
|
+
end
|
|
123
|
+
ensure
|
|
124
|
+
@state.pop_scope
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
object
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def call_method(object, name, args)
|
|
131
|
+
proto = @state.prototypes[object.proto_name]
|
|
132
|
+
fn = proto && proto[:methods][name.to_s]
|
|
133
|
+
raise RuntimeError, "#{object.proto_name} has no method .#{name}" unless fn
|
|
134
|
+
seed = captured_seed(fn).merge('self' => object)
|
|
135
|
+
if fn[:async]
|
|
136
|
+
async_seed = @evaluator.worker_snapshot(@state.locals_snapshot.merge(seed))
|
|
137
|
+
async_args = @evaluator.worker_snapshot(args)
|
|
138
|
+
return Language::TaskValue.new do
|
|
139
|
+
invoke_rsh_body("#{object.proto_name}.#{name}", fn[:params], fn[:body], fn[:line], async_args, async_seed)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
invoke_rsh_body("#{object.proto_name}.#{name}", fn[:params], fn[:body], fn[:line], args, seed)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def captured_seed(definition)
|
|
146
|
+
captured = definition[:captured]
|
|
147
|
+
case captured
|
|
148
|
+
when Language::NamespaceValue then captured.members.dup
|
|
149
|
+
when Hash then captured.dup
|
|
150
|
+
else {}
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def register_function_node(node, name: node.name, captured: nil)
|
|
155
|
+
@state.functions[name] = { params: node.params, body: node.body, line: node.number,
|
|
156
|
+
async: node.is_a?(Language::TaskFunctionNode), captured: captured }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def register_trait_node(node, name: node.name, captured: nil)
|
|
160
|
+
methods = node.body.to_h do |part|
|
|
161
|
+
[part.name, { params: part.params, body: part.body, line: part.number,
|
|
162
|
+
async: part.is_a?(Language::TaskFunctionNode), captured: captured }]
|
|
163
|
+
end.freeze
|
|
164
|
+
@state.traits[name] = { methods: methods, line: node.number, captured: captured }.freeze
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def register_proto_node(node, name: node.name, captured: nil, trait_prefix: nil)
|
|
168
|
+
slots = node.body.select { |part| part.is_a?(Language::SlotNode) }.freeze
|
|
169
|
+
methods = {}
|
|
170
|
+
node.traits.each do |trait_name|
|
|
171
|
+
qualified = trait_prefix ? "#{trait_prefix}.#{trait_name}" : nil
|
|
172
|
+
trait = (qualified && @state.traits[qualified]) || @state.traits[trait_name]
|
|
173
|
+
raise RuntimeError, "unknown trait #{trait_name} for proto #{name}" unless trait
|
|
174
|
+
methods.merge!(trait[:methods])
|
|
175
|
+
end
|
|
176
|
+
node.body.select { |part| part.is_a?(Language::FunctionNode) || part.is_a?(Language::TaskFunctionNode) }.each do |part|
|
|
177
|
+
methods[part.name] = { params: part.params, body: part.body, line: part.number,
|
|
178
|
+
async: part.is_a?(Language::TaskFunctionNode), captured: captured }
|
|
179
|
+
end
|
|
180
|
+
@state.prototypes[name] = { params: node.params.freeze, traits: node.traits, slots: slots,
|
|
181
|
+
methods: methods.freeze, line: node.number, captured: captured }.freeze
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def define_space(node)
|
|
185
|
+
key = :"srsh_space_prefix_#{object_id}"
|
|
186
|
+
parent_prefix = Thread.current[key]
|
|
187
|
+
prefix = parent_prefix ? "#{parent_prefix}.#{node.name}" : node.name
|
|
188
|
+
namespace = Language::NamespaceValue.new(prefix)
|
|
189
|
+
module_source = Thread.current[:"srsh_module_source_#{object_id}"]
|
|
190
|
+
namespace.set('$0', module_source) if module_source
|
|
191
|
+
old_prefix = Thread.current[key]
|
|
192
|
+
Thread.current[key] = prefix
|
|
193
|
+
|
|
194
|
+
# Predeclare callable members so constants, defaults and sibling functions
|
|
195
|
+
# can refer to definitions that appear later in the source file.
|
|
196
|
+
node.body.each do |part|
|
|
197
|
+
case part
|
|
198
|
+
when Language::FunctionNode, Language::TaskFunctionNode
|
|
199
|
+
qname = "#{prefix}.#{part.name}"
|
|
200
|
+
namespace.set(part.name, Language::FunctionRef.new(qname))
|
|
201
|
+
when Language::ProtoNode
|
|
202
|
+
qname = "#{prefix}.#{part.name}"
|
|
203
|
+
namespace.set(part.name, Language::PrototypeRef.new(qname))
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
node.body.grep(Language::TraitNode).each do |part|
|
|
208
|
+
register_trait_node(part, name: "#{prefix}.#{part.name}", captured: namespace)
|
|
209
|
+
end
|
|
210
|
+
node.body.grep(Language::FunctionNode).each do |part|
|
|
211
|
+
register_function_node(part, name: "#{prefix}.#{part.name}", captured: namespace)
|
|
212
|
+
end
|
|
213
|
+
node.body.grep(Language::TaskFunctionNode).each do |part|
|
|
214
|
+
register_function_node(part, name: "#{prefix}.#{part.name}", captured: namespace)
|
|
215
|
+
end
|
|
216
|
+
node.body.grep(Language::ProtoNode).each do |part|
|
|
217
|
+
register_proto_node(part, name: "#{prefix}.#{part.name}", captured: namespace, trait_prefix: prefix)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
@state.push_scope(namespace.members)
|
|
221
|
+
begin
|
|
222
|
+
node.body.each do |part|
|
|
223
|
+
next if part.is_a?(Language::FunctionNode) || part.is_a?(Language::TaskFunctionNode) ||
|
|
224
|
+
part.is_a?(Language::TraitNode) || part.is_a?(Language::ProtoNode)
|
|
225
|
+
run_nodes([part])
|
|
226
|
+
end
|
|
227
|
+
ensure
|
|
228
|
+
@state.pop_scope
|
|
229
|
+
Thread.current[key] = old_prefix
|
|
230
|
+
end
|
|
231
|
+
@state.local_define(node.name, namespace)
|
|
232
|
+
@state.last_status = 0
|
|
233
|
+
namespace
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def use_module(node)
|
|
237
|
+
requested = @evaluator.parse_eval(node.path, line: node.number).to_s
|
|
238
|
+
caller = @state.local_get('$0').to_s
|
|
239
|
+
base = !caller.empty? && File.file?(caller) ? File.dirname(File.expand_path(caller)) : Dir.pwd
|
|
240
|
+
path = File.expand_path(requested, base)
|
|
241
|
+
raise RuntimeError, "module not found: #{requested}" unless File.file?(path)
|
|
242
|
+
raise RuntimeError, "module too large: #{requested}" if File.size(path) > 4 * 1024 * 1024
|
|
243
|
+
stack_key = :"srsh_module_stack_#{object_id}"
|
|
244
|
+
stack = Thread.current[stack_key] ||= []
|
|
245
|
+
raise RuntimeError, "module cycle: #{(stack + [path]).join(' -> ')}" if stack.include?(path)
|
|
246
|
+
source = File.binread(path, 4 * 1024 * 1024 + 1)
|
|
247
|
+
raise RuntimeError, "module too large: #{requested}" if source.bytesize > 4 * 1024 * 1024
|
|
248
|
+
source.force_encoding(Encoding::UTF_8)
|
|
249
|
+
raise RuntimeError, "module is not valid UTF-8: #{requested}" unless source.valid_encoding?
|
|
250
|
+
body = Language::ProgramParser.new(source).parse
|
|
251
|
+
name = node.name || File.basename(path, File.extname(path)).gsub(/[^A-Za-z0-9_]/, '_')
|
|
252
|
+
raise RuntimeError, "bad module namespace #{name.inspect}" unless name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
253
|
+
source_key = :"srsh_module_source_#{object_id}"
|
|
254
|
+
old_source = Thread.current[source_key]
|
|
255
|
+
stack << path
|
|
256
|
+
Thread.current[source_key] = path
|
|
257
|
+
begin
|
|
258
|
+
define_space(Language::SpaceNode.new(name, body, node.number))
|
|
259
|
+
ensure
|
|
260
|
+
Thread.current[source_key] = old_source
|
|
261
|
+
stack.pop
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def defer_stack
|
|
266
|
+
Thread.current[:"srsh_defer_stack_#{object_id}"] ||= []
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def register_defer(body)
|
|
270
|
+
raise RuntimeError, 'defer used outside an execution scope' if defer_stack.empty?
|
|
271
|
+
defer_stack.last << body
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def with_defer_scope
|
|
275
|
+
actions = []
|
|
276
|
+
defer_stack << actions
|
|
277
|
+
begin
|
|
278
|
+
yield
|
|
279
|
+
ensure
|
|
280
|
+
# Keep this scope active while cleanups run: a cleanup can itself
|
|
281
|
+
# defer another cleanup, and it should run immediately after it.
|
|
282
|
+
while (body = actions.pop)
|
|
283
|
+
run_nodes(body)
|
|
284
|
+
end
|
|
285
|
+
defer_stack.pop
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def define_bridge(node)
|
|
290
|
+
path = @evaluator.parse_eval(node.library, line: node.number)
|
|
291
|
+
path = path.to_s
|
|
292
|
+
library = Language::NativeLibraryValue.new(node.name, path)
|
|
293
|
+
node.symbols.each do |symbol|
|
|
294
|
+
library.set(symbol.name, Language::NativeFunctionValue.new(library.handle, symbol.name, symbol.params, symbol.result))
|
|
295
|
+
end
|
|
296
|
+
@state.local_define(node.name, library)
|
|
297
|
+
@state.last_status = 0
|
|
298
|
+
library
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def invoke_rsh_body(label, params, body, line, args, seed = {})
|
|
302
|
+
with_call_depth(label) do
|
|
303
|
+
scope = { '$0' => label }.merge(seed)
|
|
304
|
+
args.each_with_index { |value, index| scope["$#{index + 1}"] = value }
|
|
305
|
+
@state.push_scope(scope)
|
|
306
|
+
begin
|
|
307
|
+
bind_params(params, args, line)
|
|
308
|
+
with_defer_scope { run_nodes(body) }
|
|
309
|
+
nil
|
|
310
|
+
rescue ReturnSignal => signal
|
|
311
|
+
signal.value
|
|
312
|
+
ensure
|
|
313
|
+
@state.pop_scope
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def bind_params(params, args, line)
|
|
319
|
+
arg_index = 0
|
|
320
|
+
params.each do |param, default|
|
|
321
|
+
if param.start_with?('*')
|
|
322
|
+
@state.local_define(param[1..], args[arg_index..] || [])
|
|
323
|
+
arg_index = args.length
|
|
324
|
+
next
|
|
325
|
+
end
|
|
326
|
+
value = if arg_index < args.length
|
|
327
|
+
args[arg_index]
|
|
328
|
+
elsif default
|
|
329
|
+
@evaluator.parse_eval(default, line: line)
|
|
330
|
+
else
|
|
331
|
+
nil
|
|
332
|
+
end
|
|
333
|
+
@state.local_define(param, value)
|
|
334
|
+
arg_index += 1
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def with_call_depth(label)
|
|
339
|
+
key = :"srsh_fn_depth_#{object_id}"
|
|
340
|
+
depth = Thread.current[key].to_i
|
|
341
|
+
raise RuntimeError, "#{label}: call depth exceeded" if depth >= MAX_FUNCTION_DEPTH
|
|
342
|
+
Thread.current[key] = depth + 1
|
|
343
|
+
yield
|
|
344
|
+
ensure
|
|
345
|
+
Thread.current[key] = [Thread.current[key].to_i - 1, 0].max if defined?(key)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def run_program(nodes)
|
|
349
|
+
with_defer_scope { run_nodes(nodes) }
|
|
350
|
+
@state.last_status
|
|
351
|
+
rescue ReturnSignal => signal
|
|
352
|
+
@state.last_status = signal.value.is_a?(Integer) ? signal.value : 0
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def run_nodes(nodes)
|
|
356
|
+
nodes.each do |node|
|
|
357
|
+
case node
|
|
358
|
+
when Language::Command
|
|
359
|
+
execute_line(node.line)
|
|
360
|
+
when Language::Assign
|
|
361
|
+
assign(node)
|
|
362
|
+
when Language::DestructureNode
|
|
363
|
+
destructure(node)
|
|
364
|
+
when Language::Emit
|
|
365
|
+
@app.out.puts stringify(@evaluator.parse_eval(node.expr, line: node.number))
|
|
366
|
+
@state.last_status = 0
|
|
367
|
+
when Language::ExprNode
|
|
368
|
+
value = @evaluator.parse_eval(node.expr, line: node.number)
|
|
369
|
+
@state.last_status = value.is_a?(Integer) ? value : 0
|
|
370
|
+
when Language::IfNode
|
|
371
|
+
branch = @evaluator.truthy?(@evaluator.parse_eval(node.cond, line: node.number)) ? node.yes : node.no
|
|
372
|
+
run_nodes(branch)
|
|
373
|
+
when Language::LoopNode
|
|
374
|
+
iterate(@evaluator.parse_eval(node.expr, line: node.number), node.name, node.body)
|
|
375
|
+
when Language::WhileNode
|
|
376
|
+
guard = 0
|
|
377
|
+
while @evaluator.truthy?(@evaluator.parse_eval(node.cond, line: node.number))
|
|
378
|
+
guard += 1
|
|
379
|
+
raise RuntimeError, 'loop iteration safety limit exceeded' if guard > 10_000_000
|
|
380
|
+
begin
|
|
381
|
+
run_nodes(node.body)
|
|
382
|
+
rescue NextSignal
|
|
383
|
+
next
|
|
384
|
+
rescue BreakSignal
|
|
385
|
+
break
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
when Language::FunctionNode, Language::TaskFunctionNode
|
|
389
|
+
register_function_node(node)
|
|
390
|
+
when Language::TraitNode
|
|
391
|
+
register_trait_node(node)
|
|
392
|
+
@state.last_status = 0
|
|
393
|
+
when Language::ProtoNode
|
|
394
|
+
register_proto_node(node)
|
|
395
|
+
@state.local_define(node.name, Language::PrototypeRef.new(node.name))
|
|
396
|
+
@state.last_status = 0
|
|
397
|
+
when Language::SpaceNode
|
|
398
|
+
define_space(node)
|
|
399
|
+
when Language::BridgeNode
|
|
400
|
+
define_bridge(node)
|
|
401
|
+
when Language::UseNode
|
|
402
|
+
use_module(node)
|
|
403
|
+
when Language::DeferNode
|
|
404
|
+
register_defer(node.body)
|
|
405
|
+
@state.last_status = 0
|
|
406
|
+
when Language::SlotNode
|
|
407
|
+
raise RuntimeError, 'slot declarations are only valid inside proto blocks'
|
|
408
|
+
when Language::CodeNode
|
|
409
|
+
@state.local_define(node.name, Language::CodeValue.new(node.source, node.body.freeze))
|
|
410
|
+
@state.last_status = 0
|
|
411
|
+
when Language::ReturnNode
|
|
412
|
+
value = node.expr.empty? ? nil : @evaluator.parse_eval(node.expr, line: node.number)
|
|
413
|
+
raise ReturnSignal, value
|
|
414
|
+
when Language::BreakNode
|
|
415
|
+
raise BreakSignal
|
|
416
|
+
when Language::NextNode
|
|
417
|
+
raise NextSignal
|
|
418
|
+
when Language::MatchNode
|
|
419
|
+
run_match(node)
|
|
420
|
+
when Language::TryNode
|
|
421
|
+
run_try(node)
|
|
422
|
+
else
|
|
423
|
+
raise RuntimeError, "unknown program node #{node.class}"
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def wait_job(ref)
|
|
429
|
+
job = resolve_job(ref)
|
|
430
|
+
raise RuntimeError, 'wait: no such job' unless job
|
|
431
|
+
job.refresh!
|
|
432
|
+
return 0 if job.done?
|
|
433
|
+
status = wait_group(job)
|
|
434
|
+
job.notified = true if job.done?
|
|
435
|
+
@state.last_status = status
|
|
436
|
+
status
|
|
437
|
+
rescue SystemCallError => e
|
|
438
|
+
@app.err.puts "wait: #{e.message}"
|
|
439
|
+
1
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def foreground_job(ref)
|
|
443
|
+
job = resolve_job(ref)
|
|
444
|
+
raise RuntimeError, 'fg: no such job' unless job
|
|
445
|
+
job.refresh!
|
|
446
|
+
raise RuntimeError, 'fg: job has already finished' if job.done?
|
|
447
|
+
job.mark_foreground!
|
|
448
|
+
begin
|
|
449
|
+
Process.kill('CONT', -job.pgid)
|
|
450
|
+
rescue Errno::ESRCH
|
|
451
|
+
job.refresh!
|
|
452
|
+
raise RuntimeError, 'fg: job has already finished'
|
|
453
|
+
end
|
|
454
|
+
job.mark_running!
|
|
455
|
+
give_terminal(job.pgid)
|
|
456
|
+
begin
|
|
457
|
+
status = wait_group(job)
|
|
458
|
+
ensure
|
|
459
|
+
reclaim_terminal
|
|
460
|
+
end
|
|
461
|
+
@state.last_status = status
|
|
462
|
+
status
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def background_job(ref)
|
|
466
|
+
job = resolve_job(ref)
|
|
467
|
+
raise RuntimeError, 'bg: no such job' unless job
|
|
468
|
+
job.refresh!
|
|
469
|
+
raise RuntimeError, 'bg: job has already finished' if job.done?
|
|
470
|
+
Process.kill('CONT', -job.pgid)
|
|
471
|
+
job.mark_background!
|
|
472
|
+
job.mark_running!
|
|
473
|
+
0
|
|
474
|
+
rescue SystemCallError => e
|
|
475
|
+
@app.err.puts "bg: #{e.message}"
|
|
476
|
+
1
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def find_executable(name)
|
|
480
|
+
return nil if name.to_s.empty?
|
|
481
|
+
if name.include?('/')
|
|
482
|
+
return name if File.file?(name) && File.executable?(name)
|
|
483
|
+
return nil
|
|
484
|
+
end
|
|
485
|
+
key = [name, ENV['PATH']]
|
|
486
|
+
cached = @command_cache[key]
|
|
487
|
+
return cached if cached && File.executable?(cached)
|
|
488
|
+
ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |dir|
|
|
489
|
+
path = File.join(dir.empty? ? '.' : dir, name)
|
|
490
|
+
if File.file?(path) && File.executable?(path)
|
|
491
|
+
@command_cache[key] = path
|
|
492
|
+
return path
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
nil
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
private
|
|
499
|
+
|
|
500
|
+
def split_connectors(line)
|
|
501
|
+
lex = Lexer.scan(line)
|
|
502
|
+
out = []
|
|
503
|
+
buf = []
|
|
504
|
+
connector = :seq
|
|
505
|
+
last_operator = nil
|
|
506
|
+
lex.each do |tok|
|
|
507
|
+
if tok.type == :op && %w[; && ||].include?(tok.text)
|
|
508
|
+
raise ParseError, "empty command before #{tok.text}" if buf.empty?
|
|
509
|
+
out << [connector, join_lexemes(buf)]
|
|
510
|
+
connector = tok.text == '&&' ? :and : tok.text == '||' ? :or : :seq
|
|
511
|
+
last_operator = tok.text
|
|
512
|
+
buf.clear
|
|
513
|
+
else
|
|
514
|
+
buf << tok
|
|
515
|
+
last_operator = nil
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
if buf.empty? && %w[&& ||].include?(last_operator)
|
|
519
|
+
raise ParseError, "missing command after #{last_operator}"
|
|
520
|
+
end
|
|
521
|
+
out << [connector, join_lexemes(buf)] unless buf.empty?
|
|
522
|
+
out
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def execute_pipeline(text, capture: false)
|
|
526
|
+
expanded = expand_alias(text)
|
|
527
|
+
if (m = expanded.match(/\A([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/m))
|
|
528
|
+
value = begin
|
|
529
|
+
Shellwords.shellsplit(m[2]).join(' ')
|
|
530
|
+
rescue ArgumentError
|
|
531
|
+
m[2]
|
|
532
|
+
end
|
|
533
|
+
ENV[m[1]] = value
|
|
534
|
+
return 0
|
|
535
|
+
end
|
|
536
|
+
lexemes = Lexer.scan(expanded)
|
|
537
|
+
background = lexemes.last&.type == :op && lexemes.last.text == '&'
|
|
538
|
+
lexemes.pop if background
|
|
539
|
+
|
|
540
|
+
parts = []
|
|
541
|
+
current = []
|
|
542
|
+
lexemes.each do |tok|
|
|
543
|
+
if tok.type == :op && tok.text == '|'
|
|
544
|
+
raise ParseError, 'empty pipeline stage' if current.empty?
|
|
545
|
+
parts << current
|
|
546
|
+
current = []
|
|
547
|
+
else
|
|
548
|
+
current << tok
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
raise ParseError, 'empty pipeline stage' if current.empty?
|
|
552
|
+
parts << current
|
|
553
|
+
|
|
554
|
+
stages = parts.map { |part| parse_stage(part) }
|
|
555
|
+
stages = normalize_legacy_external_fallbacks(stages)
|
|
556
|
+
pipeline = Pipeline.new(stages, background, expanded)
|
|
557
|
+
@state.run_hooks(:pre_cmd, expanded)
|
|
558
|
+
status = if stages.length == 1 && !background && parent_builtin_or_function?(stages[0])
|
|
559
|
+
execute_parent(stages[0])
|
|
560
|
+
else
|
|
561
|
+
spawn_pipeline(pipeline, capture: capture)
|
|
562
|
+
end
|
|
563
|
+
@state.run_hooks(:post_cmd, expanded, status)
|
|
564
|
+
status
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def parse_stage(lexemes)
|
|
568
|
+
words = []
|
|
569
|
+
stdin_path = stdout_path = stderr_path = nil
|
|
570
|
+
stdout_append = stderr_append = false
|
|
571
|
+
i = 0
|
|
572
|
+
while i < lexemes.length
|
|
573
|
+
tok = lexemes[i]
|
|
574
|
+
if tok.type == :op && %w[< > >> 2> 2>>].include?(tok.text)
|
|
575
|
+
path_tok = lexemes[i + 1]
|
|
576
|
+
raise ParseError, "#{tok.text}: missing path" unless path_tok&.type == :word
|
|
577
|
+
redirect_words = expand_command_word(path_tok.text)
|
|
578
|
+
raise ParseError, "#{tok.text}: ambiguous redirect" if redirect_words.length > 1
|
|
579
|
+
path = redirect_words.first
|
|
580
|
+
raise ParseError, "#{tok.text}: empty path" if path.nil? || path.empty?
|
|
581
|
+
case tok.text
|
|
582
|
+
when '<' then stdin_path = path
|
|
583
|
+
when '>' then stdout_path = path; stdout_append = false
|
|
584
|
+
when '>>' then stdout_path = path; stdout_append = true
|
|
585
|
+
when '2>' then stderr_path = path; stderr_append = false
|
|
586
|
+
when '2>>' then stderr_path = path; stderr_append = true
|
|
587
|
+
end
|
|
588
|
+
i += 2
|
|
589
|
+
elsif tok.type == :op
|
|
590
|
+
raise ParseError, "unexpected operator #{tok.text}"
|
|
591
|
+
else
|
|
592
|
+
words.concat(expand_command_word(tok.text))
|
|
593
|
+
i += 1
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
raise ParseError, 'empty command' if words.empty?
|
|
597
|
+
Stage.new(words, stdin_path, stdout_path, stdout_append, stderr_path, stderr_append)
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
def shellsplit_word(text)
|
|
601
|
+
Shellwords.shellsplit(text)
|
|
602
|
+
rescue ArgumentError => e
|
|
603
|
+
raise ParseError, e.message
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Command words need shell expansion, not RSH's list-valued glob(). Keep
|
|
607
|
+
# quoted/escaped wildcard characters literal and expand only metacharacters
|
|
608
|
+
# that were actually unquoted in the command word.
|
|
609
|
+
def expand_command_word(raw)
|
|
610
|
+
expanded = expand_text(raw)
|
|
611
|
+
word, pattern, has_glob = decode_shell_word(expanded)
|
|
612
|
+
word = expand_tilde(word)
|
|
613
|
+
pattern = expand_tilde(pattern)
|
|
614
|
+
return [word] unless has_glob
|
|
615
|
+
matches = Dir.glob(pattern).sort
|
|
616
|
+
matches.empty? ? [word] : matches
|
|
617
|
+
rescue ArgumentError => e
|
|
618
|
+
raise ParseError, e.message
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def decode_shell_word(text)
|
|
622
|
+
word = +''
|
|
623
|
+
pattern = +''
|
|
624
|
+
quote = nil
|
|
625
|
+
escaped = false
|
|
626
|
+
has_glob = false
|
|
627
|
+
text.each_char do |char|
|
|
628
|
+
if escaped
|
|
629
|
+
word << char
|
|
630
|
+
pattern << (glob_meta?(char) ? "\\#{char}" : char)
|
|
631
|
+
escaped = false
|
|
632
|
+
next
|
|
633
|
+
end
|
|
634
|
+
if char == '\\' && quote != "'"
|
|
635
|
+
escaped = true
|
|
636
|
+
next
|
|
637
|
+
end
|
|
638
|
+
if quote
|
|
639
|
+
if char == quote
|
|
640
|
+
quote = nil
|
|
641
|
+
else
|
|
642
|
+
word << char
|
|
643
|
+
pattern << (glob_meta?(char) ? "\\#{char}" : char)
|
|
644
|
+
end
|
|
645
|
+
next
|
|
646
|
+
end
|
|
647
|
+
if char == "'" || char == '"'
|
|
648
|
+
quote = char
|
|
649
|
+
next
|
|
650
|
+
end
|
|
651
|
+
word << char
|
|
652
|
+
pattern << char
|
|
653
|
+
has_glob = true if glob_meta?(char)
|
|
654
|
+
end
|
|
655
|
+
raise IncompleteInput, 'trailing backslash' if escaped
|
|
656
|
+
raise IncompleteInput, 'unterminated quote' if quote
|
|
657
|
+
[word, pattern, has_glob]
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def glob_meta?(char) = char == '*' || char == '?' || char == '['
|
|
661
|
+
|
|
662
|
+
def expand_tilde(text)
|
|
663
|
+
return @app.paths.home if text == '~'
|
|
664
|
+
return File.join(@app.paths.home, text[2..]) if text.start_with?('~/')
|
|
665
|
+
text
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def expand_alias(text)
|
|
669
|
+
seen = []
|
|
670
|
+
current = text
|
|
671
|
+
MAX_ALIAS_DEPTH.times do
|
|
672
|
+
first = Lexer.scan(current).find { |t| t.type == :word }
|
|
673
|
+
break unless first
|
|
674
|
+
name = Shellwords.shellsplit(first.text).first rescue first.text
|
|
675
|
+
replacement = @state.aliases[name]
|
|
676
|
+
break unless replacement
|
|
677
|
+
raise RuntimeError, "alias loop involving #{name}" if seen.include?(name)
|
|
678
|
+
seen << name
|
|
679
|
+
prefix = current.index(first.text)
|
|
680
|
+
current = current[0...prefix] + replacement + current[(prefix + first.text.length)..]
|
|
681
|
+
end
|
|
682
|
+
raise RuntimeError, 'alias expansion too deep' if seen.length >= MAX_ALIAS_DEPTH
|
|
683
|
+
current
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def expand_text(text)
|
|
687
|
+
out = +''
|
|
688
|
+
i = 0
|
|
689
|
+
single = double = false
|
|
690
|
+
while i < text.length
|
|
691
|
+
c = text[i]
|
|
692
|
+
|
|
693
|
+
if c == '\\' && !single && i + 1 < text.length
|
|
694
|
+
# Preserve the escape for Shellwords and, critically, do not expand
|
|
695
|
+
# the escaped next character (e.g. \$HOME stays literal).
|
|
696
|
+
out << c << text[i + 1]
|
|
697
|
+
i += 2
|
|
698
|
+
next
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
if c == "'" && !double
|
|
702
|
+
single = !single
|
|
703
|
+
out << c
|
|
704
|
+
i += 1
|
|
705
|
+
next
|
|
706
|
+
elsif c == '"' && !single
|
|
707
|
+
double = !double
|
|
708
|
+
out << c
|
|
709
|
+
i += 1
|
|
710
|
+
next
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
if !single && c == '$'
|
|
714
|
+
if text[i + 1] == '('
|
|
715
|
+
inner, finish = extract_substitution(text, i + 2)
|
|
716
|
+
value = capture(inner)
|
|
717
|
+
out << (double ? shell_escape_for_double(value) : Shellwords.escape(value))
|
|
718
|
+
i = finish + 1
|
|
719
|
+
next
|
|
720
|
+
elsif text[i + 1] == '?'
|
|
721
|
+
out << @state.last_status.to_s
|
|
722
|
+
i += 2
|
|
723
|
+
next
|
|
724
|
+
elsif text[i + 1] == '!'
|
|
725
|
+
out << @state.last_bg_pid.to_s
|
|
726
|
+
i += 2
|
|
727
|
+
next
|
|
728
|
+
elsif text[i + 1] == '{'
|
|
729
|
+
close = text.index('}', i + 2)
|
|
730
|
+
raise ParseError, 'unterminated ${...}' unless close
|
|
731
|
+
name = text[(i + 2)...close]
|
|
732
|
+
out << variable_value(name, double: double)
|
|
733
|
+
i = close + 1
|
|
734
|
+
next
|
|
735
|
+
elsif text[i + 1]&.match?(/[0-9]/)
|
|
736
|
+
j = i + 1
|
|
737
|
+
j += 1 while text[j]&.match?(/[0-9]/)
|
|
738
|
+
out << variable_value("$#{text[(i + 1)...j]}", double: double)
|
|
739
|
+
i = j
|
|
740
|
+
next
|
|
741
|
+
elsif text[i + 1]&.match?(/[A-Za-z_]/)
|
|
742
|
+
j = i + 1
|
|
743
|
+
j += 1 while text[j]&.match?(/[A-Za-z0-9_]/)
|
|
744
|
+
out << variable_value(text[(i + 1)...j], double: double)
|
|
745
|
+
i = j
|
|
746
|
+
next
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
out << c
|
|
750
|
+
i += 1
|
|
751
|
+
end
|
|
752
|
+
out
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
def variable_value(name, double: false)
|
|
756
|
+
value = if name.start_with?('$')
|
|
757
|
+
found = @state.local_defined?(name)
|
|
758
|
+
raise RuntimeError, "undefined positional #{name}" if !found && @state.options['nounset']
|
|
759
|
+
@state.local_get(name).to_s
|
|
760
|
+
elsif @state.local_defined?(name)
|
|
761
|
+
@state.local_get(name).to_s
|
|
762
|
+
elsif ENV.key?(name)
|
|
763
|
+
ENV[name].to_s
|
|
764
|
+
else
|
|
765
|
+
raise RuntimeError, "undefined variable #{name}" if @state.options['nounset']
|
|
766
|
+
''
|
|
767
|
+
end
|
|
768
|
+
double ? shell_escape_for_double(value) : Shellwords.escape(value)
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
def shell_escape_for_double(value)
|
|
772
|
+
value.to_s.gsub(/[\\\"`$]/) { |m| "\\#{m}" }
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
def extract_substitution(text, start)
|
|
776
|
+
depth = 1
|
|
777
|
+
quote = nil
|
|
778
|
+
escaped = false
|
|
779
|
+
i = start
|
|
780
|
+
while i < text.length
|
|
781
|
+
c = text[i]
|
|
782
|
+
if escaped
|
|
783
|
+
escaped = false
|
|
784
|
+
elsif c == '\\'
|
|
785
|
+
escaped = true
|
|
786
|
+
elsif quote
|
|
787
|
+
quote = nil if c == quote
|
|
788
|
+
elsif c == "'" || c == '"'
|
|
789
|
+
quote = c
|
|
790
|
+
elsif c == '(' && text[i - 1] == '$'
|
|
791
|
+
depth += 1
|
|
792
|
+
elsif c == ')'
|
|
793
|
+
depth -= 1
|
|
794
|
+
return [text[start...i], i] if depth.zero?
|
|
795
|
+
end
|
|
796
|
+
i += 1
|
|
797
|
+
end
|
|
798
|
+
raise ParseError, 'unterminated command substitution'
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
# The original shell only handled bare `ls [dir]` internally and sent
|
|
802
|
+
# option-heavy forms to the system ls. Convert that fallback into a real
|
|
803
|
+
# external pipeline stage so signals, redirections and process groups all
|
|
804
|
+
# go through SRSH's normal executor instead of spawning a grandchild from
|
|
805
|
+
# inside the builtin.
|
|
806
|
+
def normalize_legacy_external_fallbacks(stages)
|
|
807
|
+
stages.map do |stage|
|
|
808
|
+
words = stage.words
|
|
809
|
+
complex_ls = words[0] == 'ls' && (words.length > 2 || words[1].to_s.start_with?('-'))
|
|
810
|
+
path = complex_ls ? find_executable('ls') : nil
|
|
811
|
+
next stage unless path
|
|
812
|
+
Stage.new([path, *words[1..]], stage.stdin_path, stage.stdout_path, stage.stdout_append,
|
|
813
|
+
stage.stderr_path, stage.stderr_append)
|
|
814
|
+
end
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
def parent_builtin_or_function?(stage)
|
|
818
|
+
name = stage.words[0]
|
|
819
|
+
@app.builtins.key?(name) || function?(name)
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def execute_parent(stage)
|
|
823
|
+
with_parent_redirections(stage) do
|
|
824
|
+
name = stage.words[0]
|
|
825
|
+
if @app.builtins.key?(name)
|
|
826
|
+
@app.builtins.call(name, stage.words).to_i
|
|
827
|
+
else
|
|
828
|
+
value = call_function(name, stage.words[1..])
|
|
829
|
+
value.is_a?(Integer) ? value : 0
|
|
830
|
+
end
|
|
831
|
+
end
|
|
832
|
+
rescue Srsh::RuntimeError => e
|
|
833
|
+
@app.err.puts "#{stage.words[0]}: #{e.message}"
|
|
834
|
+
1
|
|
835
|
+
rescue StandardError => e
|
|
836
|
+
@app.err.puts "#{stage.words[0]}: #{e.class}: #{e.message}"
|
|
837
|
+
1
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
def with_parent_redirections(stage)
|
|
841
|
+
saved = [STDIN.dup, STDOUT.dup, STDERR.dup]
|
|
842
|
+
old_out = @app.out
|
|
843
|
+
old_err = @app.err
|
|
844
|
+
files = []
|
|
845
|
+
if stage.stdin_path
|
|
846
|
+
f = File.open(stage.stdin_path, 'r'); files << f; STDIN.reopen(f)
|
|
847
|
+
end
|
|
848
|
+
if stage.stdout_path
|
|
849
|
+
f = open_output_file(stage.stdout_path, stage.stdout_append); files << f; STDOUT.reopen(f); @app.out = f
|
|
850
|
+
end
|
|
851
|
+
if stage.stderr_path
|
|
852
|
+
f = File.open(stage.stderr_path, stage.stderr_append ? 'a' : 'w'); files << f; STDERR.reopen(f); @app.err = f
|
|
853
|
+
end
|
|
854
|
+
yield
|
|
855
|
+
ensure
|
|
856
|
+
@app.out = old_out if defined?(old_out)
|
|
857
|
+
@app.err = old_err if defined?(old_err)
|
|
858
|
+
STDIN.reopen(saved[0]) rescue nil
|
|
859
|
+
STDOUT.reopen(saved[1]) rescue nil
|
|
860
|
+
STDERR.reopen(saved[2]) rescue nil
|
|
861
|
+
saved&.each { |io| io.close rescue nil }
|
|
862
|
+
files&.each { |io| io.close rescue nil }
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
def open_output_file(path, append)
|
|
866
|
+
if !append && @state.options['noclobber'] && File.exist?(path)
|
|
867
|
+
raise RuntimeError, "noclobber: refusing to overwrite #{path}"
|
|
868
|
+
end
|
|
869
|
+
File.open(path, append ? 'a' : 'w')
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
def spawn_pipeline(pipeline, capture: false)
|
|
873
|
+
pipes = Array.new(pipeline.stages.length - 1) { IO.pipe }
|
|
874
|
+
pids = []
|
|
875
|
+
pgid = nil
|
|
876
|
+
|
|
877
|
+
pipeline.stages.each_with_index do |stage, index|
|
|
878
|
+
pid = fork do
|
|
879
|
+
begin
|
|
880
|
+
Signal.trap('INT', 'DEFAULT')
|
|
881
|
+
Signal.trap('QUIT', 'DEFAULT')
|
|
882
|
+
Signal.trap('TSTP', 'DEFAULT')
|
|
883
|
+
desired_pgid = pgid || Process.pid
|
|
884
|
+
Process.setpgid(0, desired_pgid) rescue nil
|
|
885
|
+
|
|
886
|
+
if index.positive?
|
|
887
|
+
STDIN.reopen(pipes[index - 1][0])
|
|
888
|
+
elsif stage.stdin_path
|
|
889
|
+
STDIN.reopen(File.open(stage.stdin_path, 'r'))
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
if index < pipeline.stages.length - 1
|
|
893
|
+
STDOUT.reopen(pipes[index][1])
|
|
894
|
+
elsif stage.stdout_path
|
|
895
|
+
STDOUT.reopen(open_output_file(stage.stdout_path, stage.stdout_append))
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
if stage.stderr_path
|
|
899
|
+
STDERR.reopen(File.open(stage.stderr_path, stage.stderr_append ? 'a' : 'w'))
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
pipes.flatten.each { |io| io.close rescue nil }
|
|
903
|
+
@app.out = STDOUT
|
|
904
|
+
@app.err = STDERR
|
|
905
|
+
status = execute_child_stage(stage)
|
|
906
|
+
STDOUT.flush rescue nil
|
|
907
|
+
STDERR.flush rescue nil
|
|
908
|
+
exit!(status.to_i & 0xff)
|
|
909
|
+
rescue Errno::ENOENT
|
|
910
|
+
STDERR.puts "srsh: command not found: #{stage.words[0]}"
|
|
911
|
+
exit!(127)
|
|
912
|
+
rescue Errno::EACCES
|
|
913
|
+
STDERR.puts "srsh: permission denied: #{stage.words[0]}"
|
|
914
|
+
exit!(126)
|
|
915
|
+
rescue Exception => e
|
|
916
|
+
STDERR.puts "srsh: #{stage.words[0]}: #{e.class}: #{e.message}"
|
|
917
|
+
exit!(125)
|
|
918
|
+
end
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
pgid ||= pid
|
|
922
|
+
Process.setpgid(pid, pgid) rescue nil
|
|
923
|
+
pids << pid
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
pipes.flatten.each { |io| io.close rescue nil }
|
|
927
|
+
job = @state.add_job(Job.new(pgid: pgid, pids: pids, command: pipeline.source, background: pipeline.background))
|
|
928
|
+
|
|
929
|
+
if pipeline.background
|
|
930
|
+
@state.last_bg_pid = pgid
|
|
931
|
+
@app.out.puts "[#{job.id}] #{pgid}"
|
|
932
|
+
0
|
|
933
|
+
else
|
|
934
|
+
# A language task can launch a process, but only the shell's owner
|
|
935
|
+
# thread is allowed to hand the controlling TTY to a process group.
|
|
936
|
+
manage_terminal = !capture && !@state.worker_thread?
|
|
937
|
+
give_terminal(pgid) if manage_terminal
|
|
938
|
+
begin
|
|
939
|
+
status = wait_group(job)
|
|
940
|
+
ensure
|
|
941
|
+
reclaim_terminal if manage_terminal
|
|
942
|
+
end
|
|
943
|
+
job.notified = true if job.done?
|
|
944
|
+
status
|
|
945
|
+
end
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
def execute_child_stage(stage)
|
|
949
|
+
name = stage.words[0]
|
|
950
|
+
if @app.builtins.key?(name)
|
|
951
|
+
@app.builtins.call(name, stage.words).to_i
|
|
952
|
+
elsif function?(name)
|
|
953
|
+
value = call_function(name, stage.words[1..])
|
|
954
|
+
value.is_a?(Integer) ? value : 0
|
|
955
|
+
else
|
|
956
|
+
path = find_executable(name)
|
|
957
|
+
raise Errno::ENOENT, name unless path
|
|
958
|
+
exec(path, *stage.words[1..])
|
|
959
|
+
end
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
def wait_group(job)
|
|
963
|
+
statuses = {}
|
|
964
|
+
remaining = job.pids.dup
|
|
965
|
+
until remaining.empty?
|
|
966
|
+
begin
|
|
967
|
+
pid, status = Process.waitpid2(-job.pgid, Process::WUNTRACED)
|
|
968
|
+
job.observe(pid, status)
|
|
969
|
+
if status.stopped?
|
|
970
|
+
job.mark_stopped!
|
|
971
|
+
break
|
|
972
|
+
elsif status.exited?
|
|
973
|
+
statuses[pid] = status.exitstatus || 0
|
|
974
|
+
remaining.delete(pid)
|
|
975
|
+
elsif status.signaled?
|
|
976
|
+
statuses[pid] = 128 + status.termsig
|
|
977
|
+
remaining.delete(pid)
|
|
978
|
+
end
|
|
979
|
+
rescue Errno::ECHILD
|
|
980
|
+
remaining.clear
|
|
981
|
+
break
|
|
982
|
+
rescue Interrupt
|
|
983
|
+
Process.kill('INT', -job.pgid) rescue nil
|
|
984
|
+
end
|
|
985
|
+
end
|
|
986
|
+
job.refresh! unless job.stopped?
|
|
987
|
+
if @state.options['pipefail']
|
|
988
|
+
job.pids.reverse_each do |pid|
|
|
989
|
+
code = statuses[pid]
|
|
990
|
+
return code if code && code != 0
|
|
991
|
+
end
|
|
992
|
+
end
|
|
993
|
+
statuses.fetch(job.pids.last, 0)
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
def give_terminal(pgid)
|
|
997
|
+
Terminal.foreground(pgid)
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
def reclaim_terminal
|
|
1001
|
+
Terminal.foreground(Process.getpgrp)
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
def resolve_job(ref)
|
|
1005
|
+
@state.prune_jobs!
|
|
1006
|
+
return @state.jobs.reverse.find { |j| !j.done? } if ref.nil?
|
|
1007
|
+
id = ref.to_s.delete_prefix('%').to_i
|
|
1008
|
+
@state.jobs.find { |j| j.id == id }
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
def join_lexemes(items)
|
|
1012
|
+
items.map(&:text).join(' ')
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
def destructure(node)
|
|
1016
|
+
value = @evaluator.parse_eval(node.expr, line: node.number)
|
|
1017
|
+
values = case value
|
|
1018
|
+
when Array then value
|
|
1019
|
+
when Hash then value.to_a
|
|
1020
|
+
when Range then value.to_a
|
|
1021
|
+
when String then value.lines(chomp: true)
|
|
1022
|
+
else raise RuntimeError, "cannot destructure #{value.class}"
|
|
1023
|
+
end
|
|
1024
|
+
index = 0
|
|
1025
|
+
node.names.each do |name|
|
|
1026
|
+
if name.start_with?('*')
|
|
1027
|
+
@state.local_define(name[1..], values[index..] || [])
|
|
1028
|
+
index = values.length
|
|
1029
|
+
else
|
|
1030
|
+
@state.local_define(name, values[index])
|
|
1031
|
+
index += 1
|
|
1032
|
+
end
|
|
1033
|
+
end
|
|
1034
|
+
@state.last_status = 0
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
def assign(node)
|
|
1038
|
+
value = @evaluator.parse_eval(node.expr, line: node.number)
|
|
1039
|
+
target = node.target
|
|
1040
|
+
|
|
1041
|
+
if target.include?('.') && !target.start_with?('$')
|
|
1042
|
+
parts = target.split('.')
|
|
1043
|
+
owner_expr = parts[0...-1].join('.')
|
|
1044
|
+
key = parts[-1]
|
|
1045
|
+
owner = @evaluator.parse_eval(owner_expr, line: node.number)
|
|
1046
|
+
if owner.is_a?(Language::ObjectValue) && node.op != ':='
|
|
1047
|
+
rhs = value
|
|
1048
|
+
value = owner.update(key) { |current| assigned_value(node.op, current, rhs) }
|
|
1049
|
+
else
|
|
1050
|
+
current = node.op == ':=' ? nil : @evaluator.get_member_value(owner, key)
|
|
1051
|
+
value = assigned_value(node.op, current, value)
|
|
1052
|
+
@evaluator.set_member_value(owner, key, value)
|
|
1053
|
+
end
|
|
1054
|
+
@state.last_status = 0
|
|
1055
|
+
return
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
current = target.start_with?('$') ? ENV[target[1..]] : @state.local_get(target)
|
|
1059
|
+
value = assigned_value(node.op, current, value)
|
|
1060
|
+
if target.start_with?('$')
|
|
1061
|
+
raise RuntimeError, 'worker tasks cannot mutate process environment; return a value or use shared objects' if @state.worker_thread?
|
|
1062
|
+
ENV[target[1..]] = stringify(value)
|
|
1063
|
+
elsif node.op == ':='
|
|
1064
|
+
@state.local_define(target, value)
|
|
1065
|
+
else
|
|
1066
|
+
@state.local_set(target, value)
|
|
1067
|
+
end
|
|
1068
|
+
@state.last_status = 0
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
def assigned_value(op, current, value)
|
|
1072
|
+
case op
|
|
1073
|
+
when ':=' then value
|
|
1074
|
+
when '+=' then numeric_or_string_add(current, value)
|
|
1075
|
+
when '-=' then numeric(current) - numeric(value)
|
|
1076
|
+
when '*=' then numeric(current) * numeric(value)
|
|
1077
|
+
when '/='
|
|
1078
|
+
d = numeric(value)
|
|
1079
|
+
raise RuntimeError, 'division by zero' if d.zero?
|
|
1080
|
+
numeric(current).fdiv(d)
|
|
1081
|
+
when '%='
|
|
1082
|
+
d = numeric(value)
|
|
1083
|
+
raise RuntimeError, 'modulo by zero' if d.zero?
|
|
1084
|
+
numeric(current) % d
|
|
1085
|
+
when '++=' then stringify(current) + stringify(value)
|
|
1086
|
+
else raise RuntimeError, "unknown assignment operator #{op}"
|
|
1087
|
+
end
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
def iterate(value, name, body)
|
|
1091
|
+
enumerable = case value
|
|
1092
|
+
when Integer then (0...value)
|
|
1093
|
+
when Range, Array, Hash then value
|
|
1094
|
+
when String then value.each_line.map(&:chomp)
|
|
1095
|
+
else raise RuntimeError, "cannot iterate #{value.class}"
|
|
1096
|
+
end
|
|
1097
|
+
enumerable.each do |entry|
|
|
1098
|
+
scope = if name.is_a?(Array)
|
|
1099
|
+
values = entry.is_a?(Array) ? entry : [entry]
|
|
1100
|
+
name.each_with_index.to_h { |part, index| [part, values[index]] }
|
|
1101
|
+
else
|
|
1102
|
+
{ name => entry }
|
|
1103
|
+
end
|
|
1104
|
+
@state.push_scope(scope)
|
|
1105
|
+
begin
|
|
1106
|
+
run_nodes(body)
|
|
1107
|
+
rescue NextSignal
|
|
1108
|
+
next
|
|
1109
|
+
rescue BreakSignal
|
|
1110
|
+
break
|
|
1111
|
+
ensure
|
|
1112
|
+
@state.pop_scope
|
|
1113
|
+
end
|
|
1114
|
+
end
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
def run_try(node)
|
|
1118
|
+
begin
|
|
1119
|
+
run_nodes(node.body)
|
|
1120
|
+
rescue StandardError => e
|
|
1121
|
+
raise if node.catch_body.empty?
|
|
1122
|
+
@state.push_scope(node.error_name => { 'type' => e.class.name, 'message' => e.message })
|
|
1123
|
+
begin
|
|
1124
|
+
run_nodes(node.catch_body)
|
|
1125
|
+
ensure
|
|
1126
|
+
@state.pop_scope
|
|
1127
|
+
end
|
|
1128
|
+
ensure
|
|
1129
|
+
run_nodes(node.finally_body) unless node.finally_body.empty?
|
|
1130
|
+
end
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
def run_match(node)
|
|
1134
|
+
value = @evaluator.parse_eval(node.expr, line: node.number)
|
|
1135
|
+
@state.push_scope('it' => value)
|
|
1136
|
+
begin
|
|
1137
|
+
arm = node.arms.find do |candidate|
|
|
1138
|
+
pattern = candidate.pattern
|
|
1139
|
+
next true if pattern == '_'
|
|
1140
|
+
|
|
1141
|
+
if pattern.start_with?('? ')
|
|
1142
|
+
@evaluator.truthy?(@evaluator.parse_eval(pattern[2..].strip, line: candidate.number))
|
|
1143
|
+
elsif pattern.start_with?('when ')
|
|
1144
|
+
@evaluator.truthy?(@evaluator.parse_eval(pattern[5..].strip, line: candidate.number))
|
|
1145
|
+
else
|
|
1146
|
+
expected = @evaluator.parse_eval(pattern, line: candidate.number)
|
|
1147
|
+
case expected
|
|
1148
|
+
when Language::PrototypeRef
|
|
1149
|
+
value.is_a?(Language::ObjectValue) && value.proto_name == expected.name
|
|
1150
|
+
when Range, Array
|
|
1151
|
+
expected.include?(value)
|
|
1152
|
+
when Hash
|
|
1153
|
+
value.is_a?(Hash) && expected.all? { |k, v| value[k] == v || value[k.to_s] == v || value[k.to_sym] == v }
|
|
1154
|
+
else
|
|
1155
|
+
expected == value
|
|
1156
|
+
end
|
|
1157
|
+
end
|
|
1158
|
+
end
|
|
1159
|
+
ensure
|
|
1160
|
+
@state.pop_scope
|
|
1161
|
+
end
|
|
1162
|
+
run_nodes(arm.body) if arm
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
def numeric(v)
|
|
1166
|
+
return v if v.is_a?(Numeric)
|
|
1167
|
+
Float(v)
|
|
1168
|
+
rescue ArgumentError, TypeError
|
|
1169
|
+
raise RuntimeError, "expected number, got #{v.inspect}"
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
def numeric_or_string_add(a, b)
|
|
1173
|
+
return a + b if a.is_a?(Numeric) && b.is_a?(Numeric)
|
|
1174
|
+
numeric(a) + numeric(b)
|
|
1175
|
+
rescue RuntimeError
|
|
1176
|
+
stringify(a) + stringify(b)
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1179
|
+
def stringify(v) = @evaluator.format(v)
|
|
1180
|
+
end
|
|
1181
|
+
end
|
|
1182
|
+
end
|