rubsh 0.0.2 → 0.0.3

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.
data/lib/rubsh.rb CHANGED
@@ -4,143 +4,480 @@ require 'rub_readline'
4
4
  require 'prompt'
5
5
  require 'etc'
6
6
  require 'alias'
7
+ require 'shell_parser'
8
+ require 'shell_pipeline'
9
+ require 'completion'
10
+
7
11
 
8
12
  def ralias(str)
9
- Alias.parse(str)
13
+ name, value = str.to_s.strip.split(/\s+/, 2)
14
+ [name, value]
10
15
  end
11
16
 
12
17
  class Rubsh
13
- def initialize()
14
- Signal.trap('INT') {
15
- puts "Enter exit/quit to exit shell"
16
- }
17
- Signal.trap('SIGKILL') {
18
- puts "Enter exit/quit to exit shell"
19
- }
20
- end
21
-
22
- def self.iscmd?(cmd = nil)
23
- return nil unless cmd
24
- return true if File.exists? cmd
25
- ENV["PATH"].split(':').each do |dir|
26
- return true if File.exists? "#{dir}/#{cmd.split[0]}"
18
+ attr_reader :commands
19
+
20
+ def initialize
21
+ @commands = Commands.new
22
+ @binding = @commands.get_binding
23
+ @aliases = Alias.new
24
+ @block = []
25
+ @block_depth = 0
26
+ @function_sources = {}
27
+ @completions = CompletionRegistry.new
28
+
29
+ @binding.eval('$PATH = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)')
30
+
31
+
32
+ @commands.shell_executor = proc { |name, args| execute_ruby_command(name, args) }
33
+ @commands.shell_source_executor = proc { |source| execute_pipeline(ShellParser.parse(source)) }
34
+ @commands.completion_executor = proc { |name, &block| @completions.complete(name, &block) }
35
+ $rubsh_completion_registry = @completions
36
+ $rubsh_aliases = @aliases
37
+
38
+
39
+
40
+
41
+ Signal.trap('INT') { warn 'Enter exit or quit to exit shell' }
42
+ end
43
+
44
+ def self.iscmd?(cmd = nil)
45
+ return false unless cmd
46
+ executable?(cmd.to_s.split.first)
47
+ end
48
+
49
+ def self.executable?(name)
50
+ return false if name.nil? || name.empty?
51
+ return File.executable?(name) if name.include?(File::SEPARATOR)
52
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
53
+ path = File.join(dir, name)
54
+ File.executable?(path) && !File.directory?(path)
55
+ end
56
+ end
57
+
58
+ def alias(*args)
59
+ return @aliases.to_h if args.empty?
60
+ define_alias(args.shift, args.join(' '))
61
+ end
62
+
63
+ def source(filename)
64
+ ruby_lines = []
65
+ File.foreach(filename) do |line|
66
+ directive = line.strip
67
+ if directive =~ /\Aalias\s+(\S+)\s+(.+)\z/
68
+ define_alias($1, $2)
69
+ else
70
+ ruby_lines << line
71
+ end
72
+ end
73
+ source = ruby_lines.join
74
+ source.scan(/^\s*def\s+([A-Za-z_]\w*)/).each { |match| @function_sources[match.first] = source }
75
+ eval(source, @binding, filename) unless source.empty?
76
+ end
77
+
78
+ def parse_cmd(line)
79
+ return exit if line.nil?
80
+ return if line.to_s.strip.empty?
81
+ line = line.to_s
82
+ if line.match?(/\A\s*def\b.*\bend\s*\|/)
83
+ warn 'rubsh: cannot pipe a function definition; define it before running the pipeline'
84
+ return 2
85
+ end
86
+ if line =~ /\A(.+?)\s*\|\s*complete\s+--from-fish\s*\z/
87
+ imported = execute_pipeline(ShellParser.parse(Regexp.last_match(1)), capture: true)
88
+ @completions.import_fish(imported)
89
+ return 0
90
+ end
91
+ if line =~ /\A(.+?)\s*\|\s*source\s*\z/
92
+ generated = execute_pipeline(ShellParser.parse(Regexp.last_match(1)), capture: true)
93
+ eval(generated, @binding, '(pipeline source)')
94
+ return 0
95
+ end
96
+ if line =~ /\A(def\s+[A-Za-z_]\w*\s*;.*?;\s*end;?)\s*(.+)\z/
97
+ accept_ruby_line(Regexp.last_match(1))
98
+ rest = Regexp.last_match(2).sub(/\A\s*;\s*/, '')
99
+ return parse_cmd(rest) unless rest.empty?
100
+ return 0
101
+ end
102
+
103
+ return accept_ruby_line(line) if collecting_ruby?
104
+
105
+
106
+
107
+ if line =~ /\Acomplete\s+-e\s+(\S+)\s*\z/
108
+ @completions.erase($1)
109
+ return 0
110
+ elsif line =~ /\Acomplete\s+(\S+)\s*\z/
111
+ puts @completions.show($1)
112
+ return 0
113
+ end
114
+
115
+ if ruby_expression?(line)
116
+ expression = line.match?(/\$(?:PATH|argv|rubsh_status)/) ? line : expand_environment(line)
117
+ eval(expression, @binding)
118
+ return 0
119
+ end
120
+
121
+ if line =~ /\Asource\s+(.+)\z/
122
+ path = expand_tilde(Regexp.last_match(1).strip)
123
+ unless File.file?(path)
124
+ warn "rubsh: source file not found: #{path}"
125
+ return 1
27
126
  end
28
- return false
29
- end
30
-
31
- def history
32
- history_file = ENV['HOME'] + '/.rubsh/history'
33
- return unless File.exists? history_file
34
- IO.readlines("#{ENV['HOME']}/.rubsh/history").each do |line|
35
- puts line
36
- end
37
- end
38
-
39
- def alias(*args)
40
- if args.size == 0
41
- Alias.show
42
- return
43
- end
44
- first = args.shift
45
- Alias[first] = args.join(' ')
46
- end
47
- def source(fname)
48
- load fname
49
- end
50
-
51
- def parse_cmd(cmd,follow=true)
52
- exit if cmd =~ /exit|quit/
53
- exit if cmd == nil
54
- call,*args = cmd.split
55
- return unless call
56
- if Alias[call] and follow #prevent recursion
57
- parse_cmd Alias[call] + ' ' + args.join(' '), false
58
- elsif Commands.new.respond_to? call
59
- begin
60
- Commands.new.send call, cmd
61
- rescue
62
- puts $!
63
- end
64
- elsif Rubsh::iscmd? call
65
- system(cmd)
66
- else
67
- begin
68
- res = eval(cmd,Commands.new.get_binding)
69
- p res
70
- rescue Exception
71
- puts $!
72
- end
73
- end
74
- end
75
-
76
- def parse_cmd2(cmd,follow=true)
77
- exit if cmd =~ /exit|quit/
78
- exit if cmd == nil
79
- call,*args = cmd.split
80
- return unless call
81
- if Alias[call] and follow #prevent recursion
82
- parse_cmd Alias[call] + ' ' + args.join(' '), false
83
- elsif ::Rubsh::iscmd? call
84
- system(cmd)
85
- else
86
- begin
87
- p eval(cmd, Commands.new.get_binding)
88
- rescue Exception
89
- puts $!
90
- end
91
- end
92
- end
93
-
94
- def log_cmd(cmd)
95
- @fh = File.open(ENV['HOME'] + '/.rubsh/history','a+') unless @fh
96
- @fh.puts cmd unless cmd.nil? or cmd =~ /^\s*$/
97
- @fh.flush
98
- end
99
-
100
- at_exit { @fh.close if @fh }
101
-
102
- def init_readline
103
- history_file = ENV['HOME'] + '/.rubsh/history'
104
- return unless File.exists? history_file
105
- IO.readlines(history_file).each do |line|
106
- line.chomp!
107
- Readline::HISTORY.push line
108
- end
109
- end
110
-
111
- def run
112
- ENV['PR1'] ||= '[%h:%w] %u%% '
113
- if not File.exists? "#{ENV['HOME']}/.rubsh"
114
- FileUtils.mkdir "#{ENV['HOME']}/.rubsh"
115
- end
116
- if File.exists? "#{ENV['HOME']}/.rubsh/rc.rb"
117
- source ENV['HOME'] + '/.rubsh/rc.rb'
118
- end
119
- init_readline
120
- loop do
121
- prompt = Prompt::parse ENV['PR1']
122
- cmd = Readline::readline(prompt, true)
123
- Readline::HISTORY.pop if cmd =~ /^\s*$/
124
- log_cmd cmd
125
- parse_cmd cmd
126
- end
127
- end
127
+ source(path)
128
+ return 0
129
+ end
130
+
131
+ if line =~ /\A(?:alias|ralias)\s+(\S+)\s+(.+)\z/
132
+ define_alias($1, $2)
133
+ return 0
134
+ end
135
+ if line =~ /\Adef\s+-([esr])\s+([A-Za-z_]\w*)\s*\z/
136
+ return edit_function($2) if $1 == 'e'
137
+ return remove_function($2) if $1 == 'r'
138
+ return save_function($2)
139
+ end
140
+ if line =~ /\Atype\s+([A-Za-z_]\w*)\s*\z/
141
+ return report_type($1)
142
+ end
143
+
144
+ return accept_ruby_line(line) if ruby_block_start?(line)
145
+
146
+ if line.strip =~ /\A\$([A-Za-z_]\w*)\z/
147
+ puts ENV.fetch($1, '')
148
+ return 0
149
+ end
150
+ if line.strip =~ /\A(puts|print|p)\s+\$([A-Za-z_]\w*)\z/
151
+ value = ENV.fetch($2, '')
152
+ Kernel.public_send($1, value)
153
+ return 0
154
+ end
155
+
156
+ return exit if line.strip =~ /\A(?:exit|quit)\z/
157
+
158
+ execute_pipeline(expand_alias(ShellParser.parse(line)))
159
+ rescue SyntaxError, StandardError => error
160
+ warn "rubsh: #{error.message}"
161
+ 1
162
+ end
163
+
164
+ def execute_pipeline(pipeline, capture: false)
165
+ stages = pipeline.stages.map { |stage| expand_stage(stage) }
166
+ sync_path_environment
167
+
168
+ if stages.length == 1 && stages.first.first == 'cd'
169
+ return @commands.call('cd', stages.first.drop(1), stderr: $stderr)
170
+ end
171
+
172
+ runtime = stages.map do |argv|
173
+ callable = callable_for(argv.first)
174
+ ShellPipelineStage.new(argv, callable: callable)
175
+ end
176
+ target = capture ? StringIO.new : $stdout
177
+ result = ShellPipeline.new(runtime, stdout: target, stderr: $stderr).run
178
+ capture ? result.stdout.sub(/\n\z/, '') : result.returncode
179
+ end
180
+
181
+ def run(command = nil)
182
+ ENV['PR1'] ||= '[%h:%w] %u%% '
183
+ directory = File.join(ENV['HOME'], '.config', 'rubsh')
184
+ defs = File.join(directory, 'defs')
185
+ completions = File.join(directory, 'completions')
186
+
187
+ FileUtils.mkdir_p(completions)
188
+ Dir[File.join(completions, '*.rb')].sort.each { |file| source(file) }
189
+ local_completions = File.expand_path('../completions', __dir__)
190
+ Dir[File.join(local_completions, '*.rb')].sort.each { |file| source(file) }
191
+
192
+
193
+ FileUtils.mkdir_p(defs)
194
+ Dir[File.join(defs, '*.rb')].sort.each { |file| source(file) }
195
+ config = File.join(directory, 'config.rb')
196
+ source(config) if File.exist?(config)
197
+ return parse_cmd(command) unless command.nil?
198
+ init_readline
199
+ loop do
200
+ prompt = @block.empty? ? prompt_for_readline : '> '
201
+ line = Reline.readline(prompt, true)
202
+ break if line.nil?
203
+ Reline::HISTORY.pop if line.strip.empty?
204
+ log_cmd(line)
205
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
206
+ status = parse_cmd(line)
207
+ ENV['CMD_DURATION'] = (((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round).to_s
208
+ $rubsh_status = status
209
+ $stdout.write("\n") if $stdout.respond_to?(:tty?) && $stdout.tty?
210
+
211
+ end
212
+ end
213
+
214
+ def prompt_text
215
+ return Prompt.parse(ENV['PR1']) unless @commands.command?('sh_prompt')
216
+ @binding.eval('sh_prompt').to_s
217
+ rescue StandardError => error
218
+ warn "rubsh: sh_prompt: #{error.message}"
219
+ Prompt.parse(ENV['PR1'])
220
+ end
221
+
222
+ def prompt_for_readline
223
+ lines = prompt_text.split("\n", -1)
224
+ unless lines.length == 1
225
+ $stdout.write(lines[0...-1].join("\n"))
226
+ $stdout.write("\n")
227
+ end
228
+ lines[-1]
229
+ end
230
+
231
+ def history
232
+ file = File.join(ENV['HOME'], '.rubsh', 'history')
233
+ return unless File.exist?(file)
234
+ File.foreach(file) { |line| puts line }
235
+ end
236
+
237
+ def log_cmd(command)
238
+ return if command.nil? || command.strip.empty?
239
+ @fh ||= File.open(File.join(ENV['HOME'], '.rubsh', 'history'), 'a+')
240
+ @fh.puts(command)
241
+ @fh.flush
242
+ rescue SystemCallError
243
+ @fh = nil
244
+ end
245
+
246
+ def init_readline
247
+ file = File.join(ENV['HOME'], '.rubsh', 'history')
248
+ return unless File.exist?(file)
249
+ File.foreach(file) { |line| Reline::HISTORY.push(line.chomp) }
250
+ end
251
+
252
+ private
253
+
254
+ def collecting_ruby?
255
+ !@block.empty?
256
+ end
257
+
258
+ def ruby_block_start?(line)
259
+ line.lstrip.start_with?('def ')
260
+ end
261
+
262
+ def accept_ruby_line(line)
263
+ @block << line
264
+ @block_depth += line.scan(/\bdef\b/).length
265
+ @block_depth -= line.scan(/\bend\b/).length
266
+ return if @block_depth > 0
267
+ original_source = @block.join("\n")
268
+ source = transform_ruby_source(original_source)
269
+ function_name = original_source[/\A\s*def\s+([A-Za-z_]\w*)/, 1]
270
+ @function_sources[function_name] = original_source if function_name
271
+ @block = []
272
+ eval(source, @binding)
273
+ end
274
+
275
+ def execute_ruby_command(name, args)
276
+ words = [ShellParser::Word.new([name.to_s])] + args.map { |arg| ShellParser::Word.new([arg.to_s]) }
277
+ execute_pipeline(ShellParser::Pipeline.new([ShellParser::Stage.new(words)]))
278
+ nil
279
+ end
280
+
281
+ def transform_ruby_source(source)
282
+ source.lines.map do |line|
283
+ inline = line.match(/^(\s*)def\s+([A-Za-z_]\w*)\s*;\s*([A-Za-z_]\w*(?:\s+.+?))\s*;\s*end\s*;?\s*$/)
284
+ if inline && self.class.executable?(inline[3].split.first)
285
+ "#{inline[1]}def #{inline[2]}; __rubsh_shell__(#{inline[3].inspect}); end\n"
286
+ else
287
+ match = line.match(/^(\s*)([A-Za-z_]\w*)(?:\s+(.+?))?\s*$/)
288
+ if match && self.class.executable?(match[2])
289
+ "#{match[1]}__rubsh_shell__(#{line.strip.inspect})\n"
290
+ else
291
+ line
292
+ end
293
+ end
294
+ end.join
295
+ end
296
+
297
+ def save_function(name)
298
+ source = @function_sources[name]
299
+ alias_definition = @aliases[name]
300
+ unless source || alias_definition
301
+ warn "rubsh: function not found: #{name}"
302
+ return 1
303
+ end
304
+ directory = File.join(ENV['HOME'], '.config', 'rubsh', 'defs')
305
+ FileUtils.mkdir_p(directory)
306
+ path = File.join(directory, "#{name}.rb")
307
+ if alias_definition
308
+ File.write(path, "alias #{name} #{alias_definition.inspect}\n")
309
+ else
310
+ File.write(path, format_ruby_definition(source, color: false) + "\n")
311
+ end
312
+ 0
313
+ rescue SystemCallError => error
314
+ warn "rubsh: #{error.message}"
315
+ 1
316
+ end
317
+
318
+ def remove_function(name)
319
+ @function_sources.delete(name)
320
+ @aliases.delete(name)
321
+ method = name.to_sym
322
+ if @commands.singleton_class.instance_methods(false).include?(method)
323
+ @commands.singleton_class.send(:remove_method, method)
324
+ elsif @commands.class.instance_methods(false).include?(method)
325
+ @commands.class.send(:remove_method, method)
326
+ end
327
+ path = File.join(ENV['HOME'], '.config', 'rubsh', 'defs', "#{name}.rb")
328
+ File.delete(path) if File.file?(path)
329
+ 0
330
+ rescue SystemCallError => error
331
+ warn "rubsh: #{error.message}"
332
+ 1
333
+ end
334
+
335
+ def edit_function(name)
336
+ directory = File.join(ENV['HOME'], '.config', 'rubsh', 'defs')
337
+ FileUtils.mkdir_p(directory)
338
+ path = File.join(directory, "#{name}.rb")
339
+ source = @function_sources[name] || "def #{name}\nend\n"
340
+ File.write(path, format_ruby_definition(source, color: false) + "\n") unless File.exist?(path)
341
+ editor = ENV['EDITOR'] || ENV['VISUAL']
342
+ unless editor && !editor.empty?
343
+ warn 'rubsh: $EDITOR is not set'
344
+ return 1
345
+ end
346
+ return 1 unless system(editor, path)
347
+ source(path)
348
+ 0
349
+ rescue SystemCallError => error
350
+ warn "rubsh: #{error.message}"
351
+ 1
352
+ end
353
+
354
+ def report_type(name)
355
+ if (definition = @aliases[name])
356
+ puts "#{name} is a function with definition"
357
+ puts '# Defined via alias'
358
+ puts format_shell_definition(name, definition)
359
+ elsif @commands.command?(name)
360
+ puts "#{name} is a function with definition"
361
+ puts '# Defined via Ruby'
362
+ puts format_ruby_definition(@function_sources[name] || "def #{name}\nend")
363
+ elsif self.class.executable?(name)
364
+ path = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).map { |dir| File.join(dir, name) }.find { |candidate| File.executable?(candidate) }
365
+ puts "#{name} is #{path}"
366
+ else
367
+ puts "#{name} not found"
368
+ return 1
369
+ end
370
+ 0
371
+ end
372
+
373
+ def format_ruby_definition(source, color: true)
374
+ lines = source.lines.map(&:rstrip)
375
+ lines.each_with_index.map do |line, index|
376
+ formatted = index > 0 && index < lines.length - 1 && !line.empty? ? " #{line.lstrip}" : line
377
+ highlight_ruby(formatted, color: color)
378
+ end.join("\n")
379
+ end
380
+
381
+ def format_shell_definition(name, definition)
382
+ ["function #{name}", " #{definition} $argv", 'end'].map { |line| highlight_shell(line) }.join("\n")
383
+ end
384
+
385
+ def highlight_ruby(line, color: true)
386
+ return line unless color && $stdout.respond_to?(:tty?) && $stdout.tty?
387
+ line.gsub(/\b(def|end|return)\b/, "\e[35m\\1\e[0m").gsub(/([\"'].*?[\"'])/, "\e[32m\\1\e[0m")
388
+ end
389
+
390
+ def highlight_shell(line)
391
+ return line unless $stdout.respond_to?(:tty?) && $stdout.tty?
392
+ line.gsub(/\b(function|end)\b/, "\e[36m\\1\e[0m")
393
+ end
394
+
395
+ def define_alias(name, value)
396
+ value = value.strip
397
+ value = value[1...-1] if value.length >= 2 && value[0] == value[-1] && ['"', "'"].include?(value[0])
398
+ @aliases[name.to_s] = value
399
+ end
400
+
401
+ def expand_alias(pipeline)
402
+ first = pipeline.stages.first
403
+ return pipeline unless first && first.words.first&.literal?
404
+ name = first.words.first.value
405
+ replacement = @aliases[name]
406
+ return pipeline unless replacement
407
+ raise RuntimeError, "alias recursion: #{name}" if replacement.include?(name)
408
+ replacement_stage = ShellParser.parse(replacement).stages.first
409
+ ShellParser::Pipeline.new([ShellParser::Stage.new(replacement_stage.words + first.words.drop(1))] + pipeline.stages.drop(1))
410
+ end
411
+
412
+ def expand_stage(stage)
413
+ stage.words.flat_map do |word|
414
+ value = word.parts.map do |part|
415
+ if part.is_a?(ShellParser::Substitution)
416
+ execute_substitution(part.source)
417
+ else
418
+ expand_tilde(expand_environment(part))
419
+ end
420
+ end.join
421
+ expand_glob(value)
422
+ end
423
+ end
424
+
425
+ def expand_glob(value)
426
+ return [value] unless value.match?(/[\*\?\[]/)
427
+ matches = Dir.glob(value)
428
+ matches.empty? ? [value] : matches
429
+ end
430
+
431
+
432
+
433
+ def expand_environment(value)
434
+ value.gsub(/\$([A-Za-z_]\w*)/) { ENV.fetch(Regexp.last_match(1), '') }
435
+ end
436
+
437
+ def sync_path_environment
438
+ path = @binding.eval('$PATH')
439
+ ENV['PATH'] = path.map(&:to_s).join(File::PATH_SEPARATOR) if path.is_a?(Array)
440
+ end
441
+
442
+ def expand_tilde(value)
443
+ return value unless value == '~' || value.start_with?('~/')
444
+ File.expand_path(value, ENV['HOME'])
445
+ end
446
+
447
+ def ruby_expression?(line)
448
+ return true if line.match?(/\A\s*[A-Za-z_]\w*\s*=\s*[^=]/)
449
+ return true if line.match?(/\A\s*\$PATH\b/)
450
+ return true if line.match?(/\A\s*(?:puts|print|p)\s+\$(?:PATH|argv|rubsh_status)\s*\z/)
451
+
452
+ if line =~ /\A\s*(?:puts|print|p)\s+([a-z_]\w*)\s*\z/
453
+ return @binding.local_variable_defined?($1.to_sym)
454
+ end
455
+ line =~ /\A\s*([a-z_]\w*)\s*\z/ && @binding.local_variable_defined?($1.to_sym)
456
+ end
457
+
458
+ def execute_substitution(source)
459
+ execute_pipeline(expand_alias(ShellParser.parse(source)), capture: true).to_s
460
+ end
461
+
462
+ def callable_for(name)
463
+ return nil if self.class.executable?(name)
464
+ return nil unless @commands.command?(name)
465
+ proc { |argv| @commands.call(name, argv.drop(1)) }
466
+ end
128
467
  end
129
468
 
130
469
  class String
131
470
  def ls
132
- Dir.glob self
471
+ Dir.glob(self)
133
472
  end
134
473
  end
135
474
 
136
475
  class Array
137
- def du(opt='')
138
- switch = ''
139
- if (opt.class == Symbol)
140
- switch = '-' + opt.to_s
141
- else
142
- switch = opt
476
+ def du(opt = '')
477
+ switch = opt.is_a?(Symbol) ? "-#{opt}" : opt.to_s
478
+ each do |file|
479
+ pid = Process.spawn('du', switch, file.to_s)
480
+ Process.wait(pid)
143
481
  end
144
- self.each { |f| system("du #{switch} '#{f}'"); }
145
482
  end
146
483
  end