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,5 @@
1
+ module Srsh
2
+ module Language
3
+ Token = Data.define(:type, :value, :line, :column)
4
+ end
5
+ end
@@ -0,0 +1,392 @@
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
+ # C _Bool has the size and alignment of an unsigned char. Ruby 3.2's
54
+ # Fiddle does not expose TYPE_BOOL, so use its portable ABI spelling.
55
+ 'bool' => Fiddle::TYPE_UINT8_T,
56
+ 'i8' => Fiddle::TYPE_INT8_T, 'u8' => Fiddle::TYPE_UINT8_T,
57
+ 'i16' => Fiddle::TYPE_INT16_T, 'u16' => Fiddle::TYPE_UINT16_T,
58
+ 'i32' => Fiddle::TYPE_INT32_T, 'u32' => Fiddle::TYPE_UINT32_T,
59
+ 'i64' => Fiddle::TYPE_INT64_T, 'u64' => Fiddle::TYPE_UINT64_T,
60
+ 'isize' => Fiddle::TYPE_INTPTR_T, 'usize' => Fiddle::TYPE_SIZE_T,
61
+ 'f32' => Fiddle::TYPE_FLOAT, 'f64' => Fiddle::TYPE_DOUBLE,
62
+ 'cstr' => Fiddle::TYPE_CONST_STRING, 'ptr' => Fiddle::TYPE_VOIDP
63
+ }.freeze
64
+
65
+ attr_reader :name, :params, :result
66
+
67
+ def initialize(handle, name, params, result)
68
+ @name = name.to_s.freeze
69
+ @params = params.map(&:to_s).freeze
70
+ @result = result.to_s.freeze
71
+ bad = (@params + [@result]).reject { |type| TYPES.key?(type) }
72
+ raise RuntimeError, "unknown C ABI type #{bad.first.inspect}" unless bad.empty?
73
+ raise RuntimeError, 'void is only valid as a return type' if @params.include?('void')
74
+ address = handle[@name]
75
+ @function = Fiddle::Function.new(address, @params.map { |t| TYPES.fetch(t) }, TYPES.fetch(@result))
76
+ rescue Fiddle::DLError => e
77
+ raise RuntimeError, "C symbol #{@name.inspect}: #{e.message}"
78
+ end
79
+
80
+ def call(*args)
81
+ raise RuntimeError, "#{@name} expects #{@params.length} arguments, got #{args.length}" unless args.length == @params.length
82
+ marshaled = args.zip(@params).map { |value, type| marshal_arg(value, type) }
83
+ convert_result(@function.call(*marshaled))
84
+ rescue Fiddle::DLError, TypeError, ArgumentError => e
85
+ raise RuntimeError, "C call #{@name}: #{e.message}"
86
+ end
87
+
88
+ def to_s = "cfn<#{@name}(#{@params.join(',')}) -> #{@result}>"
89
+ def inspect = to_s
90
+
91
+ private
92
+
93
+ def marshal_arg(value, type)
94
+ case type
95
+ when 'cstr' then value.to_s
96
+ when 'ptr'
97
+ case value
98
+ when nil then 0
99
+ when NativePointerValue then value.pointer
100
+ when CBufferValue then value.pointer
101
+ when Fiddle::Pointer then value
102
+ when Integer then value
103
+ else raise RuntimeError, "#{@name}: ptr argument requires cbuf/ptr/int/void"
104
+ end
105
+ when 'f32', 'f64' then Float(value)
106
+ when 'bool' then value ? 1 : 0
107
+ else Integer(value)
108
+ end
109
+ end
110
+
111
+ def convert_result(value)
112
+ case @result
113
+ when 'void' then nil
114
+ when 'bool' then value != 0 && value != false
115
+ when 'cstr'
116
+ return nil if value.nil? || (value.respond_to?(:to_i) && value.to_i.zero?)
117
+ value.is_a?(Fiddle::Pointer) ? value.to_s : Fiddle::Pointer.new(value.to_i).to_s
118
+ when 'ptr'
119
+ NativePointerValue.new(value || 0)
120
+ else value
121
+ end
122
+ end
123
+ end
124
+
125
+ class CommandValue
126
+ attr_reader :argv
127
+ def initialize(argv)
128
+ @argv = argv.map(&:to_s).freeze
129
+ raise RuntimeError, 'cmd() requires at least one argv item' if @argv.empty? || @argv[0].empty?
130
+ end
131
+ def to_s = "cmd<#{@argv.map(&:inspect).join(' ')}>"
132
+ def inspect = to_s
133
+ end
134
+
135
+ class NamespaceValue
136
+ attr_reader :name, :members
137
+
138
+ def initialize(name)
139
+ @name = name.to_s.freeze
140
+ @members = {}
141
+ end
142
+
143
+ def member?(name) = @members.key?(name.to_s)
144
+ def get(name) = @members[name.to_s]
145
+ def set(name, value) = (@members[name.to_s] = value)
146
+ def keys = @members.keys.sort
147
+ def to_s = "space<#{@name}>"
148
+ def inspect = to_s
149
+ end
150
+
151
+ class NativeLibraryValue < NamespaceValue
152
+ attr_reader :path, :handle
153
+ def initialize(name, path)
154
+ super(name)
155
+ @path = path.to_s.freeze
156
+ @handle = %w[@self self process].include?(@path) ? Fiddle::Handle::DEFAULT : Fiddle::Handle.new(@path)
157
+ rescue Fiddle::DLError => e
158
+ raise RuntimeError, "cannot load C library #{@path.inspect}: #{e.message}"
159
+ end
160
+ def to_s = "bridge<#{name}:#{@path}>"
161
+ def inspect = to_s
162
+ end
163
+
164
+
165
+ class ObjectValue
166
+ attr_reader :proto_name
167
+
168
+ def initialize(proto_name, fields = {})
169
+ @proto_name = proto_name.to_s.freeze
170
+ @fields = fields.dup
171
+ @lock = Mutex.new
172
+ end
173
+
174
+ def field?(name)
175
+ key = name.to_s
176
+ @lock.synchronize { @fields.key?(key) }
177
+ end
178
+
179
+ def get(name)
180
+ key = name.to_s
181
+ @lock.synchronize { @fields[key] }
182
+ end
183
+
184
+ def set(name, value)
185
+ key = name.to_s
186
+ @lock.synchronize { @fields[key] = value }
187
+ value
188
+ end
189
+
190
+ def update(name)
191
+ key = name.to_s
192
+ @lock.synchronize do
193
+ @fields[key] = yield(@fields[key])
194
+ end
195
+ end
196
+
197
+ def fields
198
+ @lock.synchronize { @fields.dup }
199
+ end
200
+
201
+ def copy
202
+ self.class.new(@proto_name, fields)
203
+ end
204
+
205
+ def to_s
206
+ body = fields.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')
207
+ "#{@proto_name}{#{body}}"
208
+ end
209
+
210
+ def inspect = to_s
211
+ end
212
+
213
+ class TaskValue
214
+ def initialize(&work)
215
+ @mutex = Mutex.new
216
+ @cv = ConditionVariable.new
217
+ @state = :pending
218
+ @value = nil
219
+ @error = nil
220
+ @thread = Thread.new do
221
+ begin
222
+ cancelled = @mutex.synchronize do
223
+ if @state == :cancelled
224
+ true
225
+ else
226
+ @state = :running
227
+ false
228
+ end
229
+ end
230
+ next if cancelled
231
+
232
+ value = work.call
233
+ @mutex.synchronize do
234
+ unless @state == :cancelled
235
+ @value = value
236
+ @state = :done
237
+ end
238
+ @cv.broadcast
239
+ end
240
+ rescue Exception => e # task boundary: preserve error for await()
241
+ @mutex.synchronize do
242
+ unless @state == :cancelled
243
+ @error = e
244
+ @state = :failed
245
+ end
246
+ @cv.broadcast
247
+ end
248
+ end
249
+ end
250
+ end
251
+
252
+ def await(timeout = nil)
253
+ timeout = timeout&.to_f
254
+ deadline = timeout ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout : nil
255
+ @mutex.synchronize do
256
+ until %i[done failed cancelled].include?(@state)
257
+ if deadline
258
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
259
+ raise RuntimeError, 'task await timed out' if remaining <= 0
260
+ @cv.wait(@mutex, remaining)
261
+ else
262
+ @cv.wait(@mutex)
263
+ end
264
+ end
265
+ raise RuntimeError, "task cancelled" if @state == :cancelled
266
+ if @error
267
+ raise @error if @error.is_a?(Srsh::Error)
268
+ raise RuntimeError, "task failed: #{@error.class}: #{@error.message}"
269
+ end
270
+ @value
271
+ end
272
+ end
273
+
274
+ def done?
275
+ @mutex.synchronize { %i[done failed cancelled].include?(@state) }
276
+ end
277
+
278
+ def status
279
+ @mutex.synchronize { @state.to_s }
280
+ end
281
+
282
+ def cancel
283
+ thread = nil
284
+ @mutex.synchronize do
285
+ return false if %i[done failed cancelled].include?(@state)
286
+ @state = :cancelled
287
+ thread = @thread
288
+ @cv.broadcast
289
+ end
290
+ thread.kill if thread&.alive?
291
+ true
292
+ end
293
+
294
+ def thread = @thread
295
+ def to_s = "task<#{status}>"
296
+ def inspect = to_s
297
+ end
298
+
299
+ class ChannelValue
300
+ CLOSED = Object.new.freeze
301
+
302
+ def initialize(capacity = 0)
303
+ capacity = capacity.to_i
304
+ raise RuntimeError, 'channel capacity cannot be negative' if capacity.negative?
305
+ @capacity = capacity
306
+ @queue = []
307
+ @closed = false
308
+ @mutex = Mutex.new
309
+ @readable = ConditionVariable.new
310
+ @writable = ConditionVariable.new
311
+ end
312
+
313
+ def send_value(value)
314
+ @mutex.synchronize do
315
+ raise RuntimeError, 'send on closed channel' if @closed
316
+ while @capacity.positive? && @queue.length >= @capacity
317
+ @writable.wait(@mutex)
318
+ raise RuntimeError, 'send on closed channel' if @closed
319
+ end
320
+ @queue << value
321
+ @readable.signal
322
+ end
323
+ value
324
+ end
325
+
326
+ def recv(timeout = nil)
327
+ timeout = timeout&.to_f
328
+ deadline = timeout ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout : nil
329
+ @mutex.synchronize do
330
+ while @queue.empty? && !@closed
331
+ if deadline
332
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
333
+ raise RuntimeError, 'channel receive timed out' if remaining <= 0
334
+ @readable.wait(@mutex, remaining)
335
+ else
336
+ @readable.wait(@mutex)
337
+ end
338
+ end
339
+ return nil if @queue.empty? && @closed
340
+ value = @queue.shift
341
+ @writable.signal
342
+ value
343
+ end
344
+ end
345
+
346
+ def try_recv
347
+ @mutex.synchronize do
348
+ return nil if @queue.empty?
349
+ value = @queue.shift
350
+ @writable.signal
351
+ value
352
+ end
353
+ end
354
+
355
+ def close
356
+ @mutex.synchronize do
357
+ return false if @closed
358
+ @closed = true
359
+ @readable.broadcast
360
+ @writable.broadcast
361
+ end
362
+ true
363
+ end
364
+
365
+ def closed? = @mutex.synchronize { @closed }
366
+ def size = @mutex.synchronize { @queue.length }
367
+ def to_s = "chan<#{size}#{closed? ? ',closed' : ''}>"
368
+ def inspect = to_s
369
+ end
370
+
371
+ class AtomValue
372
+ def initialize(value)
373
+ @value = value
374
+ @lock = Mutex.new
375
+ end
376
+
377
+ def get = @lock.synchronize { @value }
378
+
379
+ def set(value)
380
+ @lock.synchronize { @value = value }
381
+ value
382
+ end
383
+
384
+ def swap
385
+ @lock.synchronize { @value = yield(@value) }
386
+ end
387
+
388
+ def to_s = "atom<#{get.inspect}>"
389
+ def inspect = to_s
390
+ end
391
+ end
392
+ 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