srsh 0.8.0 → 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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +12 -3
  3. data/README.md +446 -8
  4. data/bin/srsh +71 -0
  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 +3 -0
  47. data/lib/srsh.rb +11 -5
  48. metadata +61 -14
  49. data/exe/srsh +0 -6
  50. data/lib/srsh/runner.rb +0 -2416
@@ -0,0 +1,492 @@
1
+ require 'fileutils'
2
+ require 'socket'
3
+ require 'etc'
4
+ require 'rbconfig'
5
+ require 'io/console'
6
+
7
+ module Srsh
8
+ class Builtins
9
+ def initialize(app)
10
+ @app = app
11
+ @table = {}
12
+ @dir_stack = []
13
+ install
14
+ @core_table = @table.dup.freeze
15
+ end
16
+
17
+ def register(name, &block) = @table[name.to_s] = block
18
+ def key?(name) = @table.key?(name.to_s)
19
+ def names = @table.keys
20
+ def reset_dynamic! = @table = @core_table.dup
21
+ def call(name, args) = @table.fetch(name).call(args)
22
+
23
+ private
24
+
25
+ def install
26
+ register('cd') { |a| cd(a) }
27
+ register('pwd') { |_a| @app.out.puts @app.theme.paint(Dir.pwd, :key, io: @app.out); 0 }
28
+ register('put') { |a| @app.out.puts(a[1..].join(' ')); 0 }
29
+ register('ls') { |a| ls_builtin(a) }
30
+ register('echo') { |a| @app.out.puts(a[1..].join(' ')); 0 }
31
+ register('printf') { |a| printf_builtin(a) }
32
+ register('alias') { |a| alias_builtin(a) }
33
+ register('unalias') { |a| unalias_builtin(a) }
34
+ register('set') { |a| set_builtin(a) }
35
+ register('export') { |a| export_builtin(a) }
36
+ register('option') { |a| option_builtin(a) }
37
+ register('unset') { |a| ENV.delete(a[1].to_s); a[1] ? 0 : 2 }
38
+ register('read') { |a| read_builtin(a) }
39
+ register('true') { |_a| 0 }
40
+ register('false') { |_a| 1 }
41
+ register('sleep') { |a| Kernel.sleep(Float(a[1] || 1)); 0 }
42
+ register('source') { |a| source_builtin(a) }
43
+ register('.') { |a| source_builtin(a) }
44
+ register('exit') { |a| exit(Integer(a[1] || 0)) }
45
+ register('quit') { |a| exit(Integer(a[1] || 0)) }
46
+ register('help') { |_a| help; 0 }
47
+ register('hist') { |_a| history; 0 }
48
+ register('clearhist') { |_a| @app.history.clear; 0 }
49
+ register('scheme') { |a| scheme(a) }
50
+ register('theme') { |a| scheme(a) }
51
+ register('themes') { |_a| scheme(['scheme', '--list']) }
52
+ register('plugins') { |_a| plugins; 0 }
53
+ register('reload') { |_a| @app.reload!; 0 }
54
+ register('jobs') { |_a| jobs; 0 }
55
+ register('wait') { |a| @app.executor.wait_job(a[1]) }
56
+ register('fg') { |a| @app.executor.foreground_job(a[1]) }
57
+ register('bg') { |a| @app.executor.background_job(a[1]) }
58
+ register('exec') { |a| exec_builtin(a) }
59
+ register('systemfetch') { |_a| systemfetch; 0 }
60
+ register('which') { |a| which(a) }
61
+ register('type') { |a| which(a) }
62
+ register('dirs') { |_a| dirs_builtin }
63
+ register('pushd') { |a| pushd_builtin(a) }
64
+ register('popd') { |_a| popd_builtin }
65
+ register('umask') { |a| umask_builtin(a) }
66
+ register('kill') { |a| kill_builtin(a) }
67
+ end
68
+
69
+
70
+ def exec_builtin(args)
71
+ if args.length < 2
72
+ @app.err.puts 'exec: usage: exec COMMAND [ARG ...]'
73
+ return 2
74
+ end
75
+ path = @app.executor.find_executable(args[1])
76
+ unless path
77
+ @app.err.puts "exec: command not found: #{args[1]}"
78
+ return 127
79
+ end
80
+ Kernel.exec(path, *args[2..])
81
+ rescue SystemCallError => e
82
+ @app.err.puts "exec: #{e.message}"
83
+ 126
84
+ end
85
+
86
+ def ls_builtin(args)
87
+ # Preserve the original SRSH pretty `ls` for the simple form, but hand
88
+ # option-heavy invocations to the system ls so normal Unix muscle memory
89
+ # still works.
90
+ if args.length > 2 || args[1].to_s.start_with?('-')
91
+ @app.err.puts 'ls: external ls not found'
92
+ return 127
93
+ end
94
+
95
+ dir = args[1] || '.'
96
+ entries = Dir.children(dir).sort
97
+ labels = entries.map do |name|
98
+ full = File.join(dir, name)
99
+ if File.directory?(full)
100
+ @app.theme.paint("#{name}/", :key, io: @app.out)
101
+ elsif File.executable?(full)
102
+ @app.theme.paint("#{name}*", :ok, io: @app.out)
103
+ else
104
+ name
105
+ end
106
+ end
107
+ print_columns(labels)
108
+ 0
109
+ rescue SystemCallError => e
110
+ @app.err.puts "ls: #{e.message}"
111
+ 1
112
+ end
113
+
114
+ def print_columns(labels)
115
+ return if labels.empty?
116
+ width = begin
117
+ IO.console&.winsize&.[](1)
118
+ rescue IOError, SystemCallError
119
+ nil
120
+ end
121
+ width = 80 unless width && width.positive?
122
+ plain = ->(x) { x.gsub(/\e\[[0-9;]*m/, '') }
123
+ max = labels.map { |x| plain.call(x).length }.max || 0
124
+ col_width = [max + 2, 4].max
125
+ cols = [width / col_width, 1].max
126
+ rows = (labels.length.to_f / cols).ceil
127
+ rows.times do |row|
128
+ line = +''
129
+ cols.times do |col|
130
+ idx = col * rows + row
131
+ break if idx >= labels.length
132
+ label = labels[idx]
133
+ line << label << (' ' * [col_width - plain.call(label).length, 0].max)
134
+ end
135
+ @app.out.puts line.rstrip
136
+ end
137
+ end
138
+
139
+ def cd(args)
140
+ target = args[1] || ENV['HOME'] || Dir.home
141
+ old = Dir.pwd
142
+ Dir.chdir(File.expand_path(target))
143
+ ENV['OLDPWD'] = old
144
+ ENV['PWD'] = Dir.pwd
145
+ 0
146
+ rescue SystemCallError => e
147
+ @app.err.puts "cd: #{e.message}"
148
+ 1
149
+ end
150
+
151
+ def alias_builtin(args)
152
+ if args.length == 1
153
+ @app.state.aliases.sort.each { |k, v| @app.out.puts "#{k}=#{v.inspect}" }
154
+ return 0
155
+ end
156
+ text = args[1..].join(' ')
157
+ name, value = text.split('=', 2)
158
+ if name.nil? || value.nil? || !name.match?(/\A[A-Za-z_][A-Za-z0-9_-]*\z/)
159
+ @app.err.puts 'alias: use alias name=command'
160
+ return 2
161
+ end
162
+ @app.state.aliases[name] = value
163
+ 0
164
+ end
165
+
166
+ def unalias_builtin(args)
167
+ return 2 unless args[1]
168
+ @app.state.aliases.delete(args[1])
169
+ 0
170
+ end
171
+
172
+ def set_builtin(args)
173
+ if args.length == 1
174
+ ENV.keys.sort.each { |k| @app.out.puts "#{k}=#{ENV[k]}" }
175
+ else
176
+ ENV[args[1]] = args[2..].join(' ')
177
+ end
178
+ 0
179
+ end
180
+
181
+ def printf_builtin(args)
182
+ return 0 if args.length == 1
183
+ format = args[1].to_s
184
+ values = args[2..] || []
185
+ # Shell printf is intentionally permissive. Convert obvious numerics but
186
+ # leave everything else alone so %s never surprises you.
187
+ cooked = values.map do |value|
188
+ if value.match?(/\A[-+]?\d+\z/)
189
+ value.to_i
190
+ elsif value.match?(/\A[-+]?(?:\d+\.\d*|\d*\.\d+)(?:[eE][-+]?\d+)?\z/)
191
+ value.to_f
192
+ else
193
+ value
194
+ end
195
+ end
196
+ @app.out.print(format % cooked)
197
+ 0
198
+ rescue ArgumentError => e
199
+ @app.err.puts "printf: #{e.message}"
200
+ 2
201
+ end
202
+
203
+ def export_builtin(args)
204
+ if args.length == 1
205
+ ENV.keys.sort.each { |key| @app.out.puts "export #{key}=#{ENV[key].inspect}" }
206
+ return 0
207
+ end
208
+ status = 0
209
+ args[1..].each do |item|
210
+ name, value = item.split('=', 2)
211
+ unless name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
212
+ @app.err.puts "export: bad name #{name.inspect}"
213
+ status = 2
214
+ next
215
+ end
216
+ ENV[name] = value.nil? ? ENV[name].to_s : value
217
+ end
218
+ status
219
+ end
220
+
221
+ def option_builtin(args)
222
+ opts = @app.state.options
223
+ if args.length == 1
224
+ opts.keys.sort.each { |name| @app.out.puts "#{name}=#{opts[name] ? 'yes' : 'no'}" }
225
+ return 0
226
+ end
227
+ name = args[1].to_s
228
+ value = args[2]
229
+ if name == 'strict'
230
+ enabled = parse_toggle(value.nil? ? 'yes' : value)
231
+ opts['pipefail'] = enabled
232
+ opts['nounset'] = enabled
233
+ return 0
234
+ end
235
+ unless opts.key?(name)
236
+ @app.err.puts "option: unknown option #{name.inspect}"
237
+ return 2
238
+ end
239
+ if value.nil?
240
+ @app.out.puts "#{name}=#{opts[name] ? 'yes' : 'no'}"
241
+ else
242
+ opts[name] = parse_toggle(value)
243
+ end
244
+ 0
245
+ rescue ArgumentError => e
246
+ @app.err.puts "option: #{e.message}"
247
+ 2
248
+ end
249
+
250
+ def parse_toggle(value)
251
+ case value.to_s.downcase
252
+ when 'yes', 'on', 'true', '1' then true
253
+ when 'no', 'off', 'false', '0' then false
254
+ else raise ArgumentError, "expected yes/no, got #{value.inspect}"
255
+ end
256
+ end
257
+
258
+ def dirs_builtin
259
+ @app.out.puts ([Dir.pwd] + @dir_stack.reverse).join(' ')
260
+ 0
261
+ end
262
+
263
+ def pushd_builtin(args)
264
+ target = args[1] || @dir_stack.last
265
+ unless target
266
+ @app.err.puts 'pushd: no other directory'
267
+ return 1
268
+ end
269
+ old = Dir.pwd
270
+ Dir.chdir(File.expand_path(target))
271
+ @dir_stack << old
272
+ ENV['OLDPWD'] = old
273
+ ENV['PWD'] = Dir.pwd
274
+ dirs_builtin
275
+ rescue SystemCallError => e
276
+ @app.err.puts "pushd: #{e.message}"
277
+ 1
278
+ end
279
+
280
+ def popd_builtin
281
+ target = @dir_stack.pop
282
+ unless target
283
+ @app.err.puts 'popd: directory stack empty'
284
+ return 1
285
+ end
286
+ old = Dir.pwd
287
+ Dir.chdir(target)
288
+ ENV['OLDPWD'] = old
289
+ ENV['PWD'] = Dir.pwd
290
+ dirs_builtin
291
+ rescue SystemCallError => e
292
+ @app.err.puts "popd: #{e.message}"
293
+ 1
294
+ end
295
+
296
+ def umask_builtin(args)
297
+ if args[1].nil?
298
+ old = Process.umask
299
+ Process.umask(old)
300
+ @app.out.printf "%04o\n", old
301
+ return 0
302
+ end
303
+ text = args[1].to_s
304
+ raise ArgumentError, 'mask must be octal' unless text.match?(/\A[0-7]{1,4}\z/)
305
+ Process.umask(text.to_i(8))
306
+ 0
307
+ rescue ArgumentError => e
308
+ @app.err.puts "umask: #{e.message}"
309
+ 2
310
+ end
311
+
312
+ def kill_builtin(args)
313
+ return 2 if args.length < 2
314
+ signal = 'TERM'
315
+ rest = args[1..]
316
+ if rest[0].start_with?('-')
317
+ signal = rest.shift.delete_prefix('-')
318
+ signal = signal.to_i if signal.match?(/\A\d+\z/)
319
+ end
320
+ status = 0
321
+ rest.each do |pid_text|
322
+ begin
323
+ Process.kill(signal, Integer(pid_text))
324
+ rescue SystemCallError, ArgumentError => e
325
+ @app.err.puts "kill: #{pid_text}: #{e.message}"
326
+ status = 1
327
+ end
328
+ end
329
+ status
330
+ end
331
+
332
+ def read_builtin(args)
333
+ return 2 unless args[1]
334
+ ENV[args[1]] = ($stdin.gets || '').chomp
335
+ 0
336
+ end
337
+
338
+ def source_builtin(args)
339
+ return 2 unless args[1]
340
+ @app.run_script(args[1], args[2..] || [])
341
+ @app.state.last_status
342
+ rescue StandardError => e
343
+ @app.err.puts "source: #{e.message}"
344
+ 1
345
+ end
346
+
347
+ def help
348
+ @app.out.puts @app.theme.paint("srsh #{Srsh::VERSION}: Simple Ruby Shell", :title, io: @app.out)
349
+ @app.out.puts <<~TXT
350
+
351
+ shell: cd pwd ls printf alias unalias set export unset read source
352
+ jobs wait fg bg exec which/type pushd popd dirs umask kill
353
+ shell opts: option pipefail|nounset|noclobber yes|no
354
+ option strict yes|no
355
+ info: systemfetch hist clearhist scheme/theme themes plugins reload help
356
+
357
+ RSH values: name := EXPR local binding
358
+ $NAME := EXPR process environment
359
+ = EXPR print a value
360
+ value |> fn value pipeline
361
+ $(command) process output -> value
362
+
363
+ flow: if / each / while / match / try
364
+ ? / @ / @? / ?? short forms
365
+ fn name(args) function
366
+ ::x => EXPR lambda
367
+ return / break / continue
368
+
369
+ structure: space name ... end namespace
370
+ use "file.rsh" as name module namespace
371
+ defer statement LIFO cleanup
372
+ proto / trait / slot objects + composition
373
+ code name ... end parsed code value
374
+
375
+ concurrency: task work(args) async function
376
+ &:: => EXPR spawn now
377
+ await / await_all / race
378
+ chan / atom / parallel / pmap
379
+
380
+ processes: cmd("prog", arg...) argv-safe command value
381
+ .result .capture .check .task
382
+
383
+ native C: bridge c from "lib.so"
384
+ symbol(cstr, usize) -> i32
385
+ end
386
+ cbuf(size) bounded writable memory
387
+
388
+ functional: map filter reject fold find any all count sum sort uniq
389
+ flat zip enumerate take drop chunk group each tap
390
+ partial compose
391
+
392
+ Values include lists, %[maps], ranges, interpolated "\#{...}", yes/no/void,
393
+ safe ?. / ?[] access, first-class functions/tasks/code/prototypes, and C
394
+ bridge values. `|` is a Unix process pipe; `|>` is an RSH value pipe.
395
+ TXT
396
+ end
397
+
398
+ def history
399
+ @app.history.each.with_index(1) { |line, i| @app.out.printf("%5d %s\n", i, line) }
400
+ end
401
+
402
+ def scheme(args)
403
+ if args[1].nil?
404
+ @app.out.puts @app.theme.name
405
+ return 0
406
+ end
407
+ if %w[-l --list].include?(args[1])
408
+ @app.out.puts @app.theme.names.join("\n")
409
+ return 0
410
+ end
411
+ return 0 if @app.theme.use(args[1])
412
+ @app.err.puts "scheme: unknown theme #{args[1].inspect}"
413
+ 1
414
+ end
415
+
416
+ def plugins
417
+ @app.plugins.loaded.each { |p| @app.out.puts File.basename(p) }
418
+ 0
419
+ end
420
+
421
+ def jobs
422
+ @app.state.prune_jobs!
423
+ @app.state.jobs.each do |job|
424
+ @app.out.puts "[#{job.id}] #{job.status.to_s.ljust(8)} #{job.command}"
425
+ job.notified = true if job.done?
426
+ end
427
+ 0
428
+ end
429
+
430
+ def which(args)
431
+ if args.length < 2
432
+ @app.err.puts 'which: usage: which NAME [...]'
433
+ return 2
434
+ end
435
+ status = 0
436
+ args[1..].each do |name|
437
+ if key?(name)
438
+ @app.out.puts "#{name}: srsh builtin"
439
+ elsif @app.state.functions.key?(name)
440
+ kind = @app.state.functions[name].is_a?(Srsh::Language::TaskFunctionNode) ? 'task function' : 'function'
441
+ @app.out.puts "#{name}: srsh #{kind}"
442
+ elsif @app.state.prototypes.key?(name)
443
+ @app.out.puts "#{name}: srsh prototype"
444
+ elsif @app.state.traits.key?(name)
445
+ @app.out.puts "#{name}: srsh trait"
446
+ elsif (path = @app.executor.find_executable(name))
447
+ @app.out.puts path
448
+ else
449
+ @app.err.puts "#{name}: not found"
450
+ status = 1
451
+ end
452
+ end
453
+ status
454
+ end
455
+
456
+ def systemfetch
457
+ host = Socket.gethostname
458
+ user = ENV['USER'] || Etc.getlogin || Etc.getpwuid.name rescue 'unknown'
459
+ os = if File.file?('/etc/os-release')
460
+ match = File.read('/etc/os-release').match(/^PRETTY_NAME=(?:"(.*)"|(.*))$/)
461
+ match ? (match[1] || match[2]) : RbConfig::CONFIG['host_os']
462
+ else
463
+ RbConfig::CONFIG['host_os']
464
+ end
465
+ mem = if File.file?('/proc/meminfo')
466
+ info = File.read('/proc/meminfo').scan(/^(\w+):\s+(\d+)/).to_h.transform_values { |v| v.to_i * 1024 }
467
+ total = info['MemTotal'].to_i
468
+ avail = info['MemAvailable'].to_i
469
+ total.positive? ? "#{human(total - avail)} / #{human(total)}" : 'n/a'
470
+ else
471
+ 'n/a'
472
+ end
473
+ @app.out.puts @app.theme.paint("#{user}@#{host}", :title, io: @app.out)
474
+ @app.out.puts "OS: #{os}"
475
+ @app.out.puts "Shell: srsh #{Srsh::VERSION}"
476
+ @app.out.puts "Ruby: #{RUBY_ENGINE} #{RUBY_VERSION}"
477
+ @app.out.puts "RAM: #{mem}"
478
+ 0
479
+ end
480
+
481
+ def human(bytes)
482
+ units = %w[B KiB MiB GiB TiB]
483
+ value = bytes.to_f
484
+ unit = units.shift
485
+ while value >= 1024 && !units.empty?
486
+ value /= 1024
487
+ unit = units.shift
488
+ end
489
+ format('%.1f %s', value, unit)
490
+ end
491
+ end
492
+ end