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.
- checksums.yaml +7 -0
- data/README.rdoc +31 -12
- data/bin/rubsh +9 -7
- data/lib/alias.rb +23 -13
- data/lib/commands.rb +64 -66
- data/lib/completion.rb +111 -0
- data/lib/rub_readline.rb +35 -10
- data/lib/rubsh.rb +460 -123
- data/lib/shell_parser.rb +141 -0
- data/lib/shell_pipeline.rb +131 -0
- data/rubsh.gemspec +6 -4
- data/spec/completion_spec.rb +45 -0
- data/spec/rubsh_spec.rb +177 -0
- data/test/rubsh_test.rb +45 -0
- metadata +48 -49
- data/Manifest +0 -11
data/lib/shell_parser.rb
ADDED
|
@@ -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.
|
|
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.
|
|
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/
|
|
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
|
data/spec/rubsh_spec.rb
ADDED
|
@@ -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
|
data/test/rubsh_test.rb
ADDED
|
@@ -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
|