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.
@@ -0,0 +1,141 @@
1
+ class ShellParser
2
+ Substitution = Struct.new(:source)
3
+ Word = Struct.new(:parts) do
4
+ def literal?
5
+ parts.all? { |part| part.is_a?(String) }
6
+ end
7
+
8
+ def value
9
+ parts.join
10
+ end
11
+ end
12
+ Stage = Struct.new(:words)
13
+ Pipeline = Struct.new(:stages)
14
+
15
+ def self.parse(source)
16
+ new(source).parse
17
+ end
18
+
19
+ def initialize(source)
20
+ @source = source.to_s
21
+ @index = 0
22
+ @stages = []
23
+ @words = []
24
+ @parts = []
25
+ @buffer = +''
26
+ @word_started = false
27
+ end
28
+
29
+ def parse
30
+ while @index < @source.length
31
+ char = @source[@index]
32
+ case char
33
+ when ' ', "\t", "\r", "\n"
34
+ flush_buffer
35
+ flush_word
36
+ @index += 1
37
+ when '|'
38
+ flush_buffer
39
+ flush_word
40
+ raise SyntaxError, 'empty pipeline stage' if @words.empty?
41
+ @stages << Stage.new(@words)
42
+ @words = []
43
+ @index += 1
44
+ when "'"
45
+ read_single_quote
46
+ when '"'
47
+ read_double_quote
48
+ when '\\'
49
+ @word_started = true
50
+ @index += 1
51
+ raise SyntaxError, 'trailing escape' if @index >= @source.length
52
+ @buffer << @source[@index]
53
+ @index += 1
54
+ when '`'
55
+ flush_buffer
56
+ @parts << read_substitution
57
+ else
58
+ @word_started = true
59
+ @buffer << char
60
+ @index += 1
61
+ end
62
+ end
63
+
64
+ flush_buffer
65
+ flush_word
66
+ raise SyntaxError, 'empty pipeline stage' if @words.empty? && !@stages.empty?
67
+ @stages << Stage.new(@words) unless @words.empty?
68
+ raise SyntaxError, 'empty command' if @stages.empty?
69
+ Pipeline.new(@stages)
70
+ end
71
+
72
+ private
73
+
74
+ def flush_buffer
75
+ return if @buffer.empty?
76
+ @parts << @buffer
77
+ @buffer = +''
78
+ end
79
+
80
+ def flush_word
81
+ return unless @word_started
82
+ @words << Word.new(@parts)
83
+ @parts = []
84
+ @word_started = false
85
+ end
86
+
87
+ def read_single_quote
88
+ @word_started = true
89
+ @index += 1
90
+ start = @index
91
+ finish = @source.index("'", start)
92
+ raise SyntaxError, 'unterminated single quote' unless finish
93
+ @buffer << @source[start...finish]
94
+ @index = finish + 1
95
+ end
96
+
97
+ def read_double_quote
98
+ @word_started = true
99
+ @index += 1
100
+ loop do
101
+ raise SyntaxError, 'unterminated double quote' if @index >= @source.length
102
+ char = @source[@index]
103
+ if char == '"'
104
+ @index += 1
105
+ return
106
+ elsif char == '\\'
107
+ @index += 1
108
+ raise SyntaxError, 'trailing escape' if @index >= @source.length
109
+ @buffer << @source[@index]
110
+ @index += 1
111
+ elsif char == '`'
112
+ flush_buffer
113
+ @parts << read_substitution
114
+ else
115
+ @buffer << char
116
+ @index += 1
117
+ end
118
+ end
119
+ end
120
+
121
+ def read_substitution
122
+ @word_started = true
123
+ @index += 1
124
+ start = @index
125
+ escaped = false
126
+ while @index < @source.length
127
+ char = @source[@index]
128
+ if escaped
129
+ escaped = false
130
+ elsif char == '\\'
131
+ escaped = true
132
+ elsif char == '`'
133
+ source = @source[start...@index]
134
+ @index += 1
135
+ return Substitution.new(source)
136
+ end
137
+ @index += 1
138
+ end
139
+ raise SyntaxError, 'unterminated command substitution'
140
+ end
141
+ end
@@ -0,0 +1,131 @@
1
+ require 'stringio'
2
+
3
+
4
+ class ShellPipeline
5
+ attr_reader :statuses, :stdout, :stderr
6
+
7
+ def initialize(stages, stdin: $stdin, stdout: $stdout, stderr: $stderr)
8
+ @stages = stages
9
+ @stdin = stdin
10
+ @stdout_target = stdout
11
+ @stderr_target = stderr
12
+ @statuses = []
13
+ @stdout = +''
14
+ @stderr = +''
15
+ end
16
+
17
+ def run
18
+ input = usable_input(@stdin)
19
+ processes = []
20
+ output_reader = nil
21
+ error_readers = []
22
+ inputs_to_close = []
23
+ direct_output = @stages.length == 1 && terminal?(@stdout_target)
24
+
25
+ @stages.each_with_index do |stage, index|
26
+ if direct_output
27
+ output_read = nil
28
+ output_write = @stdout_target
29
+ else
30
+ output_read, output_write = IO.pipe
31
+ end
32
+ error_read, error_write = IO.pipe
33
+ processes << stage.spawn(input, output_write, error_write)
34
+ output_write.close if output_write != @stdout_target
35
+ error_write.close
36
+ inputs_to_close << input if input != @stdin
37
+
38
+ if index == @stages.length - 1
39
+ output_reader = Thread.new do
40
+ begin
41
+ output_read.read
42
+ ensure
43
+ output_read.close unless output_read.closed?
44
+ end
45
+ end unless direct_output
46
+ else
47
+ input = output_read
48
+ end
49
+ error_readers << Thread.new do
50
+ begin
51
+ error_read.read
52
+ ensure
53
+ error_read.close unless error_read.closed?
54
+ end
55
+ end
56
+ end
57
+
58
+ inputs_to_close.each { |io| io.close unless io.closed? }
59
+ processes.each_with_index do |pid, index|
60
+ _, status = Process.wait2(pid)
61
+ @statuses[index] = status.exitstatus || 1
62
+ end
63
+ @stdout = output_reader ? output_reader.value : ''
64
+ @stderr = error_readers.map(&:value).join
65
+ @stdout_target.write(@stdout) if @stdout_target.respond_to?(:write)
66
+ @stderr_target.write(@stderr) if @stderr_target.respond_to?(:write) && !@stderr.empty?
67
+ @stdout_target.flush if @stdout_target.respond_to?(:flush)
68
+ self
69
+ ensure
70
+ input.close if input && input != @stdin && input.respond_to?(:close) && !input.closed?
71
+ end
72
+
73
+ private
74
+
75
+ def terminal?(io)
76
+ io.respond_to?(:tty?) && io.tty?
77
+ end
78
+
79
+ def usable_input(input)
80
+ return input if input.respond_to?(:fileno)
81
+ reader, writer = IO.pipe
82
+ Thread.new do
83
+ writer.write(input.read.to_s)
84
+ writer.close
85
+ end
86
+ reader
87
+ end
88
+
89
+ public
90
+
91
+ def returncode
92
+ @statuses.last || 0
93
+ end
94
+
95
+ def pipecode
96
+ @statuses.find { |status| status != 0 } || 0
97
+ end
98
+ end
99
+
100
+ class ShellPipelineStage
101
+ def initialize(argv, callable: nil)
102
+ @argv = argv
103
+ @callable = callable
104
+ end
105
+
106
+ def spawn(input, output, error)
107
+ if @callable
108
+ Process.fork do
109
+ begin
110
+ $stdin = input
111
+ $stdout = output
112
+ $stderr = error
113
+ $argv = @argv.drop(1)
114
+ unless input.tty?
115
+ data = input.read
116
+ $stdin = StringIO.new(data)
117
+ $argv.concat(data.lines(chomp: true))
118
+ end
119
+ result = @callable.call(@argv)
120
+ $stdout.write(result.to_s) if result.is_a?(String) && !result.empty?
121
+ exit! 0
122
+ rescue Exception => exception
123
+ $stderr.puts exception.message
124
+ exit! 1
125
+ end
126
+ end
127
+ else
128
+ Process.spawn(*@argv, in: input.fileno, out: output.fileno, err: error.fileno)
129
+ end
130
+ end
131
+ end
data/rubsh.gemspec CHANGED
@@ -2,23 +2,25 @@
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = %q{rubsh}
5
- s.version = "0.0.2"
5
+ s.version = "0.0.3"
6
6
 
7
7
  s.required_rubygems_version = Gem::Requirement.new(">= 1.2") if s.respond_to? :required_rubygems_version=
8
8
  s.authors = ["Daniel Bretoi"]
9
9
  s.date = %q{2010-10-09}
10
- s.default_executable = %q{rubsh}
10
+ s.bindir = %q{bin}
11
11
  s.description = %q{A ruby shell}
12
12
  s.email = %q{daniel@netwalk.org}
13
13
  s.executables = ["rubsh"]
14
- s.extra_rdoc_files = ["CHANGELOG", "README.rdoc", "bin/rubsh", "lib/alias.rb", "lib/commands.rb", "lib/prompt.rb", "lib/rub_readline.rb", "lib/rubsh.rb"]
15
- s.files = ["CHANGELOG", "README.rdoc", "Rakefile", "bin/rubsh", "lib/alias.rb", "lib/commands.rb", "lib/prompt.rb", "lib/rub_readline.rb", "lib/rubsh.rb", "rubsh.gemspec", "Manifest"]
14
+ s.extra_rdoc_files = ["CHANGELOG", "README.rdoc", "bin/rubsh", "lib/alias.rb", "lib/commands.rb", "lib/completion.rb", "lib/prompt.rb", "lib/rub_readline.rb", "lib/shell_parser.rb", "lib/shell_pipeline.rb", "lib/rubsh.rb"]
15
+ s.files = ["CHANGELOG", "README.rdoc", "Rakefile", "bin/rubsh", "lib/alias.rb", "lib/commands.rb", "lib/completion.rb", "lib/prompt.rb", "lib/rub_readline.rb", "lib/shell_parser.rb", "lib/shell_pipeline.rb", "lib/rubsh.rb", "test/rubsh_test.rb", "spec/rubsh_spec.rb", "spec/completion_spec.rb", "rubsh.gemspec"]
16
16
  s.homepage = %q{http://github.com/danielb2/rubsh}
17
17
  s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "Rubsh", "--main", "README.rdoc"]
18
18
  s.require_paths = ["lib"]
19
19
  s.rubyforge_project = %q{rubsh}
20
20
  s.rubygems_version = %q{1.3.7}
21
21
  s.summary = %q{A ruby shell}
22
+ s.add_development_dependency "rspec"
23
+
22
24
 
23
25
  if s.respond_to? :specification_version then
24
26
  current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
@@ -0,0 +1,45 @@
1
+ require 'tmpdir'
2
+ require 'rubsh'
3
+
4
+ RSpec.describe CompletionRegistry do
5
+ it 'merges stackable nested completion declarations' do
6
+ registry = described_class.new
7
+ registry.complete('lb') { subcommand 'cal'; option '-v' }
8
+ registry.complete('lb') do
9
+ subcommand 'ls' do
10
+ option '--long'
11
+ end
12
+ end
13
+ expect(registry.show('lb')).to include('cal', 'ls', '-v')
14
+ expect(registry.suggestions(['lb'], '')).to include('cal', 'ls', '-v')
15
+ expect(registry.suggestions(['lb', 'ls'], '--')).to include('--long')
16
+ end
17
+
18
+ it 'erases a command completion tree' do
19
+ registry = described_class.new
20
+ registry.complete('lb') { subcommand 'cal' }
21
+ registry.erase('lb')
22
+ expect(registry.show('lb')).to eq([])
23
+ end
24
+
25
+ it 'completes subcommands after a trailing space' do
26
+ Dir.mktmpdir do |directory|
27
+ shell = Rubsh.new
28
+ path = File.join(directory, 'lb.rb')
29
+ File.write(path, "complete 'lb' do\n subcommand 'cal'\n subcommand 'ls'\n subcommand 'add'\nend\n")
30
+ shell.send(:source, path)
31
+ allow(Reline).to receive(:line_buffer).and_return('lb ')
32
+ expect(Reline.completion_proc.call('')).to include('cal', 'ls', 'add')
33
+ end
34
+ end
35
+
36
+ it 'loads a completion definition when explicitly sourced' do
37
+ Dir.mktmpdir do |directory|
38
+ shell = Rubsh.new
39
+ path = File.join(directory, 'tool.rb')
40
+ File.write(path, "complete 'tool' do\n subcommand 'run'\nend\n")
41
+ shell.send(:source, path)
42
+ expect(shell.instance_variable_get(:@completions).show('tool')).to include('run')
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,177 @@
1
+ require 'tmpdir'
2
+ require 'rubsh'
3
+
4
+ RSpec.describe Rubsh do
5
+ let(:shell) { described_class.new }
6
+
7
+ describe ShellParser do
8
+ it 'preserves quoted and escaped argument boundaries' do
9
+ pipeline = ShellParser.parse('echo "hello world" escaped\ value | tee out.txt')
10
+ expect(pipeline.stages.map { |stage| stage.words.map(&:value) }).to eq([
11
+ ['echo', 'hello world', 'escaped value'], ['tee', 'out.txt']
12
+ ])
13
+ end
14
+
15
+ it 'represents command substitutions as nested nodes' do
16
+ pipeline = ShellParser.parse('echo `foo`')
17
+ expect(pipeline.stages.first.words.last.parts).to eq([ShellParser::Substitution.new('foo')])
18
+ end
19
+
20
+ it 'rejects malformed pipelines' do
21
+ expect { ShellParser.parse('echo |') }.to raise_error(SyntaxError)
22
+ expect { ShellParser.parse('echo `foo') }.to raise_error(SyntaxError)
23
+ end
24
+ end
25
+
26
+ it 'reports an invalid definition pipeline without raising' do
27
+ expect { expect(shell.parse_cmd('def merp; echo blah; end | merp')).to eq(2) }
28
+ .to output(/cannot pipe a function definition/).to_stderr
29
+ end
30
+
31
+ it 'runs a Ruby function through backtick substitution and tee' do
32
+ Dir.mktmpdir do |directory|
33
+ Dir.chdir(directory) do
34
+ shell.parse_cmd('def foo')
35
+ shell.parse_cmd('return "ok"')
36
+ shell.parse_cmd('end')
37
+ shell.parse_cmd('echo `foo` | tee out.txt')
38
+ expect(File.read('out.txt')).to eq("ok\n")
39
+ end
40
+ end
41
+ end
42
+
43
+ it 'runs a one-line shell command function with a trailing semicolon' do
44
+ Dir.mktmpdir do |directory|
45
+ Dir.chdir(directory) do
46
+ shell.parse_cmd('def merp; echo blah; end;')
47
+ expect { shell.parse_cmd('merp') }.to output("blah\n").to_stdout
48
+ end
49
+ end
50
+ end
51
+
52
+ it 'runs a function and following command on the same input line' do
53
+ expect { shell.parse_cmd('def merp; echo blah; end; merp') }
54
+ .to output("blah\n").to_stdout
55
+ end
56
+
57
+ it 'runs a shell command from inside a Ruby function' do
58
+ Dir.mktmpdir do |directory|
59
+ Dir.chdir(directory) do
60
+ File.write('sample', '')
61
+ shell.parse_cmd('def merp')
62
+ shell.parse_cmd('ls')
63
+ shell.parse_cmd('end')
64
+ shell.parse_cmd('merp | tee out.txt')
65
+ expect(File.read('out.txt')).to eq("out.txt\nsample\n")
66
+ end
67
+ end
68
+ end
69
+
70
+ it 'keeps cd in the parent shell' do
71
+ Dir.mktmpdir do |directory|
72
+ original = Dir.pwd
73
+ shell.parse_cmd("cd #{directory}")
74
+ expect(File.realpath(Dir.pwd)).to eq(File.realpath(directory))
75
+ ensure
76
+ Dir.chdir(original)
77
+ end
78
+ end
79
+
80
+ it 'stores aliases without losing their source text' do
81
+ shell.parse_cmd('alias say echo "hello world"')
82
+ expect(shell.alias['say']).to eq('echo "hello world"')
83
+ end
84
+
85
+ it 'reports aliases before external commands with type' do
86
+ shell.parse_cmd('alias ls "eza -l"')
87
+ expect { shell.parse_cmd('type ls') }.to output("ls is a function with definition\n# Defined via alias\nfunction ls\n eza -l $argv\nend\n").to_stdout
88
+ end
89
+
90
+ it 'prints the Ruby source for a defined function with type' do
91
+ shell.parse_cmd('def merp')
92
+ shell.parse_cmd('ls')
93
+ shell.parse_cmd('end')
94
+ expect { shell.parse_cmd('type merp') }.to output(/merp is a function with definition.*def merp.*ls.*end/m).to_stdout
95
+ end
96
+
97
+ it 'saves definitions under the config defs directory' do
98
+ Dir.mktmpdir do |home|
99
+ original_home = ENV['HOME']
100
+ ENV['HOME'] = home
101
+ shell.parse_cmd('def moo')
102
+ shell.parse_cmd('puts $argv')
103
+ shell.parse_cmd('end')
104
+ expect(shell.parse_cmd('def -s moo')).to eq(0)
105
+ saved = File.read(File.join(home, '.config', 'rubsh', 'defs', 'moo.rb'))
106
+ expect(saved).to include("def moo")
107
+ expect(saved).to include(" puts $argv")
108
+ ensure
109
+ ENV['HOME'] = original_home
110
+ end
111
+ end
112
+
113
+ it 'saves aliases as reloadable function definitions' do
114
+ Dir.mktmpdir do |home|
115
+ original_home = ENV['HOME']
116
+ ENV['HOME'] = home
117
+ shell.parse_cmd('alias ls "eza -l"')
118
+ expect(shell.parse_cmd('def -s ls')).to eq(0)
119
+ path = File.join(home, '.config', 'rubsh', 'defs', 'ls.rb')
120
+ expect(File.read(path)).to eq("alias ls \"eza -l\"\n")
121
+ reloaded = Rubsh.new
122
+ reloaded.source(path)
123
+ expect(reloaded.alias['ls']).to eq('eza -l')
124
+ ensure
125
+ ENV['HOME'] = original_home
126
+ end
127
+ end
128
+
129
+ it 'edits an unsaved function using the configured editor' do
130
+ Dir.mktmpdir do |home|
131
+ original_home = ENV['HOME']
132
+ original_editor = ENV['EDITOR']
133
+ ENV['HOME'] = home
134
+ ENV['EDITOR'] = '/usr/bin/true'
135
+ expect(shell.parse_cmd('def -e fresh')).to eq(0)
136
+ expect(File.read(File.join(home, '.config', 'rubsh', 'defs', 'fresh.rb'))).to include('def fresh')
137
+ ensure
138
+ ENV['HOME'] = original_home
139
+ ENV['EDITOR'] = original_editor
140
+ end
141
+ end
142
+
143
+ it 'sources a saved function file from a shell command' do
144
+ Dir.mktmpdir do |directory|
145
+ path = File.join(directory, 'hello.rb')
146
+ File.write(path, "def hello\n puts 'hi'\nend\n")
147
+ expect(shell.parse_cmd("source #{path}")).to eq(0)
148
+ expect { shell.parse_cmd('hello') }.to output("hi\n").to_stdout
149
+ expect { shell.parse_cmd('type hello') }.to output(/def hello.*puts 'hi'.*end/m).to_stdout
150
+
151
+ end
152
+ end
153
+
154
+
155
+ it 'evaluates Ruby assignments in the persistent context' do
156
+ shell.parse_cmd('a = 3')
157
+ expect(shell.instance_variable_get(:@binding).eval('a')).to eq(3)
158
+ end
159
+
160
+ it 'uses assigned Ruby locals in puts and p expressions' do
161
+ shell.parse_cmd('a = 3')
162
+ expect { shell.parse_cmd('puts a') }.to output("3\n").to_stdout
163
+ expect { shell.parse_cmd('p a') }.to output("3\n").to_stdout
164
+ end
165
+
166
+ it 'uses sh_prompt for the interactive prompt' do
167
+ shell.parse_cmd('def sh_prompt')
168
+ shell.parse_cmd('return "custom> "')
169
+ shell.parse_cmd('end')
170
+ expect(shell.send(:prompt_text)).to eq('custom> ')
171
+ end
172
+
173
+ it 'expands environment variables as shell values' do
174
+ expect { shell.parse_cmd('$SHELL') }.to output("#{ENV.fetch('SHELL', '')}\n").to_stdout
175
+ expect { shell.parse_cmd('puts $SHELL') }.to output("#{ENV.fetch('SHELL', '')}\n").to_stdout
176
+ end
177
+ end
@@ -0,0 +1,45 @@
1
+ require 'minitest/autorun'
2
+ require 'tmpdir'
3
+ require 'stringio'
4
+ require 'rubsh'
5
+
6
+ class RubshTest < Minitest::Test
7
+ def setup
8
+ @shell = Rubsh.new
9
+ end
10
+
11
+ def test_parser_preserves_arguments_and_pipeline
12
+ pipeline = ShellParser.parse(%q{echo "hello world" escaped\ value | tee out.txt})
13
+ assert_equal 2, pipeline.stages.length
14
+ assert_equal %w[echo hello\ world escaped\ value], pipeline.stages.first.words.map(&:value)
15
+ assert_equal %w[tee out.txt], pipeline.stages.last.words.map(&:value)
16
+ end
17
+
18
+ def test_parser_keeps_nested_substitution
19
+ pipeline = ShellParser.parse('echo `printf "ok\\n"`')
20
+ word = pipeline.stages.first.words.last
21
+ assert_instance_of ShellParser::Substitution, word.parts.first
22
+ end
23
+
24
+ def test_requested_function_pipeline
25
+ Dir.mktmpdir do |directory|
26
+ Dir.chdir(directory) do
27
+ @shell.parse_cmd('def foo')
28
+ @shell.parse_cmd('return "ok"')
29
+ @shell.parse_cmd('end')
30
+ @shell.parse_cmd('echo `foo` | tee out.txt')
31
+ assert_equal "ok\n", File.read('out.txt')
32
+ end
33
+ end
34
+ end
35
+
36
+ def test_cd_changes_parent_context
37
+ Dir.mktmpdir do |directory|
38
+ original = Dir.pwd
39
+ @shell.parse_cmd("cd #{directory}")
40
+ assert_equal File.realpath(directory), File.realpath(Dir.pwd)
41
+ ensure
42
+ Dir.chdir(original)
43
+ end
44
+ end
45
+ end