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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +18 -14
  3. data/README.md +351 -134
  4. data/bin/srsh +53 -1473
  5. data/docs/assets/slut.txt +4 -0
  6. data/docs/assets/srsh-mark.svg +12 -0
  7. data/docs/css/style.css +696 -0
  8. data/docs/index.html +703 -0
  9. data/docs/js/app.js +203 -0
  10. data/examples/bridge.rsh +8 -0
  11. data/examples/calculator.rsh +253 -0
  12. data/examples/defer.rsh +14 -0
  13. data/examples/hot.rsh +14 -0
  14. data/examples/meta.rsh +20 -0
  15. data/examples/modules/text.rsh +6 -0
  16. data/examples/modules.rsh +6 -0
  17. data/examples/paste.rsh +15 -0
  18. data/examples/plugin.rb +8 -0
  19. data/examples/power.rsh +65 -0
  20. data/examples/tour.rsh +38 -0
  21. data/ext/srsh_native/extconf.rb +3 -0
  22. data/ext/srsh_native/srsh_native.c +48 -0
  23. data/language-docs/LANGUAGE.md +670 -0
  24. data/language-docs/MIGRATION.md +44 -0
  25. data/language-docs/SECURITY.md +44 -0
  26. data/lib/srsh/app.rb +261 -0
  27. data/lib/srsh/builtins.rb +492 -0
  28. data/lib/srsh/editor.rb +530 -0
  29. data/lib/srsh/errors.rb +23 -0
  30. data/lib/srsh/history.rb +74 -0
  31. data/lib/srsh/language/evaluator.rb +1175 -0
  32. data/lib/srsh/language/lexer.rb +316 -0
  33. data/lib/srsh/language/parser.rb +997 -0
  34. data/lib/srsh/language/token.rb +5 -0
  35. data/lib/srsh/language/values.rb +392 -0
  36. data/lib/srsh/paths.rb +29 -0
  37. data/lib/srsh/plugins.rb +59 -0
  38. data/lib/srsh/process_identity.rb +38 -0
  39. data/lib/srsh/security.rb +38 -0
  40. data/lib/srsh/shell/executor.rb +1182 -0
  41. data/lib/srsh/shell/job.rb +101 -0
  42. data/lib/srsh/shell/lexer.rb +114 -0
  43. data/lib/srsh/shell/terminal.rb +26 -0
  44. data/lib/srsh/state.rb +136 -0
  45. data/lib/srsh/theme.rb +108 -0
  46. data/lib/srsh/version.rb +1 -2
  47. data/lib/srsh.rb +15 -0
  48. 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