srsh 0.8.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +390 -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 +16 -4
  48. metadata +72 -15
  49. data/exe/srsh +0 -6
  50. data/lib/srsh/runner.rb +0 -2416
@@ -0,0 +1,5 @@
1
+ module Srsh
2
+ module Language
3
+ Token = Data.define(:type, :value, :line, :column)
4
+ end
5
+ end
@@ -0,0 +1,390 @@
1
+ require 'thread'
2
+ require 'fiddle'
3
+ require_relative '../errors'
4
+
5
+ module Srsh
6
+ module Language
7
+ class FunctionRef
8
+ attr_reader :name, :captured
9
+ def initialize(name, captured = nil)
10
+ @name = name.to_s.freeze
11
+ @captured = captured
12
+ end
13
+ def to_s = "fn<#{name}>"
14
+ def inspect = to_s
15
+ end
16
+
17
+ PrototypeRef = Data.define(:name) do
18
+ def to_s = name.to_s
19
+ def inspect = to_s
20
+ end
21
+ BoundMethodValue = Data.define(:receiver, :name)
22
+ NativeMethodValue = Data.define(:receiver, :name)
23
+
24
+
25
+
26
+ class NativePointerValue
27
+ attr_reader :pointer
28
+ def initialize(pointer)
29
+ @pointer = pointer.is_a?(Fiddle::Pointer) ? pointer : Fiddle::Pointer.new(pointer.to_i)
30
+ end
31
+ def address = @pointer.to_i
32
+ def null? = address.zero?
33
+ def to_s = "ptr<0x#{address.to_s(16)}>"
34
+ def inspect = to_s
35
+ end
36
+
37
+ class CBufferValue
38
+ attr_reader :pointer, :size
39
+ def initialize(size)
40
+ @size = Integer(size)
41
+ raise RuntimeError, 'cbuf() size must be between 1 and 64 MiB' unless @size.between?(1, 64 * 1024 * 1024)
42
+ @pointer = Fiddle::Pointer.malloc(@size, Fiddle::RUBY_FREE)
43
+ @pointer[0, @size] = "\0" * @size
44
+ end
45
+ def address = @pointer.to_i
46
+ def to_s = "cbuf<#{@size}@0x#{address.to_s(16)}>"
47
+ def inspect = to_s
48
+ end
49
+
50
+ class NativeFunctionValue
51
+ TYPES = {
52
+ 'void' => Fiddle::TYPE_VOID,
53
+ 'bool' => Fiddle::TYPE_BOOL,
54
+ 'i8' => Fiddle::TYPE_INT8_T, 'u8' => Fiddle::TYPE_UINT8_T,
55
+ 'i16' => Fiddle::TYPE_INT16_T, 'u16' => Fiddle::TYPE_UINT16_T,
56
+ 'i32' => Fiddle::TYPE_INT32_T, 'u32' => Fiddle::TYPE_UINT32_T,
57
+ 'i64' => Fiddle::TYPE_INT64_T, 'u64' => Fiddle::TYPE_UINT64_T,
58
+ 'isize' => Fiddle::TYPE_INTPTR_T, 'usize' => Fiddle::TYPE_SIZE_T,
59
+ 'f32' => Fiddle::TYPE_FLOAT, 'f64' => Fiddle::TYPE_DOUBLE,
60
+ 'cstr' => Fiddle::TYPE_CONST_STRING, 'ptr' => Fiddle::TYPE_VOIDP
61
+ }.freeze
62
+
63
+ attr_reader :name, :params, :result
64
+
65
+ def initialize(handle, name, params, result)
66
+ @name = name.to_s.freeze
67
+ @params = params.map(&:to_s).freeze
68
+ @result = result.to_s.freeze
69
+ bad = (@params + [@result]).reject { |type| TYPES.key?(type) }
70
+ raise RuntimeError, "unknown C ABI type #{bad.first.inspect}" unless bad.empty?
71
+ raise RuntimeError, 'void is only valid as a return type' if @params.include?('void')
72
+ address = handle[@name]
73
+ @function = Fiddle::Function.new(address, @params.map { |t| TYPES.fetch(t) }, TYPES.fetch(@result))
74
+ rescue Fiddle::DLError => e
75
+ raise RuntimeError, "C symbol #{@name.inspect}: #{e.message}"
76
+ end
77
+
78
+ def call(*args)
79
+ raise RuntimeError, "#{@name} expects #{@params.length} arguments, got #{args.length}" unless args.length == @params.length
80
+ marshaled = args.zip(@params).map { |value, type| marshal_arg(value, type) }
81
+ convert_result(@function.call(*marshaled))
82
+ rescue Fiddle::DLError, TypeError, ArgumentError => e
83
+ raise RuntimeError, "C call #{@name}: #{e.message}"
84
+ end
85
+
86
+ def to_s = "cfn<#{@name}(#{@params.join(',')}) -> #{@result}>"
87
+ def inspect = to_s
88
+
89
+ private
90
+
91
+ def marshal_arg(value, type)
92
+ case type
93
+ when 'cstr' then value.to_s
94
+ when 'ptr'
95
+ case value
96
+ when nil then 0
97
+ when NativePointerValue then value.pointer
98
+ when CBufferValue then value.pointer
99
+ when Fiddle::Pointer then value
100
+ when Integer then value
101
+ else raise RuntimeError, "#{@name}: ptr argument requires cbuf/ptr/int/void"
102
+ end
103
+ when 'f32', 'f64' then Float(value)
104
+ when 'bool' then value ? 1 : 0
105
+ else Integer(value)
106
+ end
107
+ end
108
+
109
+ def convert_result(value)
110
+ case @result
111
+ when 'void' then nil
112
+ when 'bool' then value != 0 && value != false
113
+ when 'cstr'
114
+ return nil if value.nil? || (value.respond_to?(:to_i) && value.to_i.zero?)
115
+ value.is_a?(Fiddle::Pointer) ? value.to_s : Fiddle::Pointer.new(value.to_i).to_s
116
+ when 'ptr'
117
+ NativePointerValue.new(value || 0)
118
+ else value
119
+ end
120
+ end
121
+ end
122
+
123
+ class CommandValue
124
+ attr_reader :argv
125
+ def initialize(argv)
126
+ @argv = argv.map(&:to_s).freeze
127
+ raise RuntimeError, 'cmd() requires at least one argv item' if @argv.empty? || @argv[0].empty?
128
+ end
129
+ def to_s = "cmd<#{@argv.map(&:inspect).join(' ')}>"
130
+ def inspect = to_s
131
+ end
132
+
133
+ class NamespaceValue
134
+ attr_reader :name, :members
135
+
136
+ def initialize(name)
137
+ @name = name.to_s.freeze
138
+ @members = {}
139
+ end
140
+
141
+ def member?(name) = @members.key?(name.to_s)
142
+ def get(name) = @members[name.to_s]
143
+ def set(name, value) = (@members[name.to_s] = value)
144
+ def keys = @members.keys.sort
145
+ def to_s = "space<#{@name}>"
146
+ def inspect = to_s
147
+ end
148
+
149
+ class NativeLibraryValue < NamespaceValue
150
+ attr_reader :path, :handle
151
+ def initialize(name, path)
152
+ super(name)
153
+ @path = path.to_s.freeze
154
+ @handle = %w[@self self process].include?(@path) ? Fiddle::Handle::DEFAULT : Fiddle::Handle.new(@path)
155
+ rescue Fiddle::DLError => e
156
+ raise RuntimeError, "cannot load C library #{@path.inspect}: #{e.message}"
157
+ end
158
+ def to_s = "bridge<#{name}:#{@path}>"
159
+ def inspect = to_s
160
+ end
161
+
162
+
163
+ class ObjectValue
164
+ attr_reader :proto_name
165
+
166
+ def initialize(proto_name, fields = {})
167
+ @proto_name = proto_name.to_s.freeze
168
+ @fields = fields.dup
169
+ @lock = Mutex.new
170
+ end
171
+
172
+ def field?(name)
173
+ key = name.to_s
174
+ @lock.synchronize { @fields.key?(key) }
175
+ end
176
+
177
+ def get(name)
178
+ key = name.to_s
179
+ @lock.synchronize { @fields[key] }
180
+ end
181
+
182
+ def set(name, value)
183
+ key = name.to_s
184
+ @lock.synchronize { @fields[key] = value }
185
+ value
186
+ end
187
+
188
+ def update(name)
189
+ key = name.to_s
190
+ @lock.synchronize do
191
+ @fields[key] = yield(@fields[key])
192
+ end
193
+ end
194
+
195
+ def fields
196
+ @lock.synchronize { @fields.dup }
197
+ end
198
+
199
+ def copy
200
+ self.class.new(@proto_name, fields)
201
+ end
202
+
203
+ def to_s
204
+ body = fields.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')
205
+ "#{@proto_name}{#{body}}"
206
+ end
207
+
208
+ def inspect = to_s
209
+ end
210
+
211
+ class TaskValue
212
+ def initialize(&work)
213
+ @mutex = Mutex.new
214
+ @cv = ConditionVariable.new
215
+ @state = :pending
216
+ @value = nil
217
+ @error = nil
218
+ @thread = Thread.new do
219
+ begin
220
+ cancelled = @mutex.synchronize do
221
+ if @state == :cancelled
222
+ true
223
+ else
224
+ @state = :running
225
+ false
226
+ end
227
+ end
228
+ next if cancelled
229
+
230
+ value = work.call
231
+ @mutex.synchronize do
232
+ unless @state == :cancelled
233
+ @value = value
234
+ @state = :done
235
+ end
236
+ @cv.broadcast
237
+ end
238
+ rescue Exception => e # task boundary: preserve error for await()
239
+ @mutex.synchronize do
240
+ unless @state == :cancelled
241
+ @error = e
242
+ @state = :failed
243
+ end
244
+ @cv.broadcast
245
+ end
246
+ end
247
+ end
248
+ end
249
+
250
+ def await(timeout = nil)
251
+ timeout = timeout&.to_f
252
+ deadline = timeout ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout : nil
253
+ @mutex.synchronize do
254
+ until %i[done failed cancelled].include?(@state)
255
+ if deadline
256
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
257
+ raise RuntimeError, 'task await timed out' if remaining <= 0
258
+ @cv.wait(@mutex, remaining)
259
+ else
260
+ @cv.wait(@mutex)
261
+ end
262
+ end
263
+ raise RuntimeError, "task cancelled" if @state == :cancelled
264
+ if @error
265
+ raise @error if @error.is_a?(Srsh::Error)
266
+ raise RuntimeError, "task failed: #{@error.class}: #{@error.message}"
267
+ end
268
+ @value
269
+ end
270
+ end
271
+
272
+ def done?
273
+ @mutex.synchronize { %i[done failed cancelled].include?(@state) }
274
+ end
275
+
276
+ def status
277
+ @mutex.synchronize { @state.to_s }
278
+ end
279
+
280
+ def cancel
281
+ thread = nil
282
+ @mutex.synchronize do
283
+ return false if %i[done failed cancelled].include?(@state)
284
+ @state = :cancelled
285
+ thread = @thread
286
+ @cv.broadcast
287
+ end
288
+ thread.kill if thread&.alive?
289
+ true
290
+ end
291
+
292
+ def thread = @thread
293
+ def to_s = "task<#{status}>"
294
+ def inspect = to_s
295
+ end
296
+
297
+ class ChannelValue
298
+ CLOSED = Object.new.freeze
299
+
300
+ def initialize(capacity = 0)
301
+ capacity = capacity.to_i
302
+ raise RuntimeError, 'channel capacity cannot be negative' if capacity.negative?
303
+ @capacity = capacity
304
+ @queue = []
305
+ @closed = false
306
+ @mutex = Mutex.new
307
+ @readable = ConditionVariable.new
308
+ @writable = ConditionVariable.new
309
+ end
310
+
311
+ def send_value(value)
312
+ @mutex.synchronize do
313
+ raise RuntimeError, 'send on closed channel' if @closed
314
+ while @capacity.positive? && @queue.length >= @capacity
315
+ @writable.wait(@mutex)
316
+ raise RuntimeError, 'send on closed channel' if @closed
317
+ end
318
+ @queue << value
319
+ @readable.signal
320
+ end
321
+ value
322
+ end
323
+
324
+ def recv(timeout = nil)
325
+ timeout = timeout&.to_f
326
+ deadline = timeout ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout : nil
327
+ @mutex.synchronize do
328
+ while @queue.empty? && !@closed
329
+ if deadline
330
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
331
+ raise RuntimeError, 'channel receive timed out' if remaining <= 0
332
+ @readable.wait(@mutex, remaining)
333
+ else
334
+ @readable.wait(@mutex)
335
+ end
336
+ end
337
+ return nil if @queue.empty? && @closed
338
+ value = @queue.shift
339
+ @writable.signal
340
+ value
341
+ end
342
+ end
343
+
344
+ def try_recv
345
+ @mutex.synchronize do
346
+ return nil if @queue.empty?
347
+ value = @queue.shift
348
+ @writable.signal
349
+ value
350
+ end
351
+ end
352
+
353
+ def close
354
+ @mutex.synchronize do
355
+ return false if @closed
356
+ @closed = true
357
+ @readable.broadcast
358
+ @writable.broadcast
359
+ end
360
+ true
361
+ end
362
+
363
+ def closed? = @mutex.synchronize { @closed }
364
+ def size = @mutex.synchronize { @queue.length }
365
+ def to_s = "chan<#{size}#{closed? ? ',closed' : ''}>"
366
+ def inspect = to_s
367
+ end
368
+
369
+ class AtomValue
370
+ def initialize(value)
371
+ @value = value
372
+ @lock = Mutex.new
373
+ end
374
+
375
+ def get = @lock.synchronize { @value }
376
+
377
+ def set(value)
378
+ @lock.synchronize { @value = value }
379
+ value
380
+ end
381
+
382
+ def swap
383
+ @lock.synchronize { @value = yield(@value) }
384
+ end
385
+
386
+ def to_s = "atom<#{get.inspect}>"
387
+ def inspect = to_s
388
+ end
389
+ end
390
+ end
data/lib/srsh/paths.rb ADDED
@@ -0,0 +1,29 @@
1
+ require 'fileutils'
2
+
3
+ module Srsh
4
+ class Paths
5
+ attr_reader :home, :root, :plugins, :themes, :config, :history, :history_v1, :rc, :theme_state
6
+
7
+ def initialize(home = Dir.home)
8
+ @home = home
9
+ @root = File.join(home, '.srsh')
10
+ @plugins = File.join(root, 'plugins')
11
+ @themes = File.join(root, 'themes')
12
+ @config = File.join(root, 'config')
13
+ # Keep the original SRSH history location. 1.0.0 briefly moved it into
14
+ # ~/.srsh/history; History imports that file so neither generation is lost.
15
+ @history = File.join(home, '.srsh_history')
16
+ @history_v1 = File.join(root, 'history')
17
+ @rc = File.join(home, '.srshrc')
18
+ @theme_state = File.join(root, 'theme')
19
+ end
20
+
21
+ def ensure!
22
+ FileUtils.mkdir_p(root, mode: 0o700)
23
+ FileUtils.mkdir_p(plugins, mode: 0o700)
24
+ FileUtils.mkdir_p(themes, mode: 0o700)
25
+ [root, plugins, themes].each { |p| File.chmod(0o700, p) rescue nil }
26
+ self
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,59 @@
1
+ require_relative 'security'
2
+
3
+ module Srsh
4
+ class PluginAPI
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def builtin(name, &block) = @app.builtins.register(name, &block)
10
+ def hook(type, &block) = @app.state.hook(type, &block)
11
+ def alias_command(name, command) = @app.state.aliases[name.to_s] = command.to_s
12
+ def theme(name, values) = @app.theme.register(name, values)
13
+ def state = @app.state
14
+ end
15
+
16
+ class Plugins
17
+ MAX_PLUGIN_BYTES = 4 * 1024 * 1024
18
+ attr_reader :loaded
19
+
20
+ def initialize(app)
21
+ @app = app
22
+ @loaded = []
23
+ end
24
+
25
+ def load_all
26
+ @loaded.clear
27
+ Dir.glob(File.join(@app.paths.plugins, '*.{rsh,rb}')).sort.each { |path| load_file(path) }
28
+ end
29
+
30
+ def load_file(path)
31
+ unless Security.private_regular_file?(path)
32
+ @app.err.puts @app.theme.paint("plugin refused (unsafe owner/mode): #{path}", :warn, io: @app.err)
33
+ return false
34
+ end
35
+
36
+ case File.extname(path)
37
+ when '.rsh'
38
+ @app.run_script(path, [])
39
+ when '.rb'
40
+ api = PluginAPI.new(@app)
41
+ mod = Module.new
42
+ mod.const_set(:SRSH, api)
43
+ raise Error, 'Ruby plugin too large' if File.size(path) > MAX_PLUGIN_BYTES
44
+ source = File.binread(path, MAX_PLUGIN_BYTES + 1)
45
+ raise Error, 'Ruby plugin too large' if source.bytesize > MAX_PLUGIN_BYTES
46
+ source.force_encoding(Encoding::UTF_8)
47
+ raise Error, 'Ruby plugin is not valid UTF-8' unless source.valid_encoding?
48
+ mod.module_eval(source, path, 1)
49
+ else
50
+ return false
51
+ end
52
+ @loaded << path
53
+ true
54
+ rescue StandardError => e
55
+ @app.err.puts @app.theme.paint("plugin #{File.basename(path)}: #{e.class}: #{e.message}", :error, io: @app.err)
56
+ false
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Srsh
4
+ module ProcessIdentity
5
+ module_function
6
+
7
+ def install!(executable: $PROGRAM_NAME)
8
+ path = File.expand_path(executable.to_s)
9
+ ENV['SHELL'] = path unless path.empty?
10
+ $0 = 'srsh'
11
+ if RUBY_PLATFORM.include?('linux')
12
+ if defined?(::SrshNative) && ::SrshNative.respond_to?(:set_process_name)
13
+ ::SrshNative.set_process_name('srsh')
14
+ else
15
+ linux_process_name('srsh')
16
+ end
17
+ end
18
+ true
19
+ rescue StandardError
20
+ false
21
+ end
22
+
23
+ def linux_process_name(name)
24
+ require 'fiddle'
25
+ libc = Fiddle::Handle::DEFAULT
26
+ prctl = Fiddle::Function.new(
27
+ libc['prctl'],
28
+ [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_ULONG, Fiddle::TYPE_ULONG, Fiddle::TYPE_ULONG],
29
+ Fiddle::TYPE_INT
30
+ )
31
+ bytes = name.to_s.byteslice(0, 15).to_s + "\0"
32
+ pointer = Fiddle::Pointer[bytes]
33
+ prctl.call(15, pointer, 0, 0, 0) # PR_SET_NAME
34
+ rescue Fiddle::DLError, LoadError
35
+ false
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,38 @@
1
+ require 'tempfile'
2
+
3
+ module Srsh
4
+ module Security
5
+ module_function
6
+
7
+ def private_regular_file?(path)
8
+ st = File.lstat(path)
9
+ return false unless st.file?
10
+ return false if st.symlink?
11
+ return false unless st.uid == Process.uid
12
+ (st.mode & 0o022).zero?
13
+ rescue SystemCallError
14
+ false
15
+ end
16
+
17
+ def safe_auto_source?(path)
18
+ !File.exist?(path) || private_regular_file?(path)
19
+ end
20
+
21
+ def atomic_write(path, data, mode: 0o600)
22
+ dir = File.dirname(path)
23
+ Dir.mkdir(dir, 0o700) unless Dir.exist?(dir)
24
+ tmp = "#{path}.tmp.#{$$}.#{rand(1 << 30)}"
25
+ flags = File::WRONLY | File::CREAT | File::EXCL
26
+ File.open(tmp, flags, mode) do |f|
27
+ f.write(data)
28
+ f.flush
29
+ f.fsync rescue nil
30
+ end
31
+ File.rename(tmp, path)
32
+ File.chmod(mode, path) rescue nil
33
+ true
34
+ ensure
35
+ File.unlink(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
36
+ end
37
+ end
38
+ end