sandbox-ruby 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.
- checksums.yaml +7 -0
- data/lib/sandbox/command.rb +79 -0
- data/lib/sandbox/context.rb +186 -0
- data/lib/sandbox/shell.rb +286 -0
- data/lib/sandbox/version.rb +5 -0
- data/lib/sandbox.rb +6 -0
- metadata +47 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: d6bebcc0f50a70a8e26b1bd19147fc07ca6c121c91fb2c063a5c47d705c5362b
|
4
|
+
data.tar.gz: 663bc2289826e5d691b87ae8f012df879833072e182f549ccec4cda0d8d5245d
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: cbae14a48949f52be3a306120859bd43e61958a2e5996720bc4828ed520b7617d72af6f184ad59b63aa4af50e9186a23303197bd79c4af0632401995eb5bdf6b
|
7
|
+
data.tar.gz: 47b0258491e02735a3062df7d61c566d4ec445585f46cef1c05e86a125ab27cdd50d7d7621291a06395169fb22b5454e538be642e9d3d0a080c979ce0547c048
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sandbox
|
4
|
+
##
|
5
|
+
# Command
|
6
|
+
class Command
|
7
|
+
attr_reader :completion_proc, :aliases, :params
|
8
|
+
attr_accessor :name, :description, :global
|
9
|
+
|
10
|
+
##
|
11
|
+
# Creates a new command
|
12
|
+
def initialize(name, shell, context, block, **options)
|
13
|
+
@name = name.to_sym
|
14
|
+
@shell = shell
|
15
|
+
@context = context
|
16
|
+
@block = block
|
17
|
+
@description = options[:description]
|
18
|
+
@global = options[:global]
|
19
|
+
@aliases = options[:aliases]&.map(&:to_sym)
|
20
|
+
|
21
|
+
@params = {}
|
22
|
+
params = options[:params]
|
23
|
+
params&.each do |param|
|
24
|
+
param = param.strip
|
25
|
+
if param.start_with?('[') && param.end_with?(']')
|
26
|
+
@params[param] = false
|
27
|
+
elsif param.start_with?('<') && param.end_with?('>')
|
28
|
+
@params[param] = true
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
@completion_proc = nil
|
33
|
+
end
|
34
|
+
|
35
|
+
##
|
36
|
+
# Executes the command
|
37
|
+
def exec(tokens)
|
38
|
+
mandatory = @params.count { |_, v| v }
|
39
|
+
if mandatory > (tokens.length - 1)
|
40
|
+
print_usage
|
41
|
+
return
|
42
|
+
end
|
43
|
+
|
44
|
+
@block&.call(tokens, @shell, @context, self)
|
45
|
+
end
|
46
|
+
|
47
|
+
##
|
48
|
+
# Sets a block for the readline auto completion
|
49
|
+
def completion(&block)
|
50
|
+
@completion_proc = block
|
51
|
+
end
|
52
|
+
|
53
|
+
##
|
54
|
+
# Returns true if the command is global
|
55
|
+
def global?
|
56
|
+
@global
|
57
|
+
end
|
58
|
+
|
59
|
+
##
|
60
|
+
# Returns the string representation of the command
|
61
|
+
def to_s
|
62
|
+
@name.to_s
|
63
|
+
end
|
64
|
+
|
65
|
+
##
|
66
|
+
# Returns true if the name matches the command name or alias
|
67
|
+
def match?(name)
|
68
|
+
name = name&.to_sym
|
69
|
+
@name == name || @aliases&.include?(name)
|
70
|
+
end
|
71
|
+
|
72
|
+
##
|
73
|
+
# Prints command usage
|
74
|
+
def print_usage
|
75
|
+
@shell.print("Usage: #{@name} ")
|
76
|
+
@shell.puts(params.keys.join(' '))
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,186 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sandbox
|
4
|
+
##
|
5
|
+
# Context
|
6
|
+
class Context
|
7
|
+
INVALID_CHARS = [
|
8
|
+
'/',
|
9
|
+
'.',
|
10
|
+
'\s'
|
11
|
+
].freeze
|
12
|
+
|
13
|
+
attr_reader :contexts, :commands, :completion_proc
|
14
|
+
attr_accessor :name, :description
|
15
|
+
|
16
|
+
##
|
17
|
+
# Creates a new context
|
18
|
+
def initialize(name, shell, **options)
|
19
|
+
@name = name.to_sym
|
20
|
+
@shell = shell
|
21
|
+
@description = options[:description]
|
22
|
+
|
23
|
+
@contexts = []
|
24
|
+
@commands = []
|
25
|
+
@completion_proc = nil
|
26
|
+
end
|
27
|
+
|
28
|
+
##
|
29
|
+
# Adds a new context to the current context
|
30
|
+
def add_context(name, **options)
|
31
|
+
raise ContextError, "Context #{name} contains invalid characters" if invalid_chars?(name)
|
32
|
+
|
33
|
+
name = name.downcase.to_sym
|
34
|
+
raise ContextError, "Context #{name} already exists in context #{self}" if context?(name)
|
35
|
+
raise ContextError, "Command #{name} already exists in context #{self}" if command?(name)
|
36
|
+
|
37
|
+
@contexts << Context.new(name, @shell, **options)
|
38
|
+
@contexts.last
|
39
|
+
end
|
40
|
+
|
41
|
+
##
|
42
|
+
# Removes a context from the current context
|
43
|
+
def remove_context(name)
|
44
|
+
name = name.downcase.to_sym
|
45
|
+
raise ContextError, "Context #{name} doesn't exists in context #{self}" unless context?(name)
|
46
|
+
|
47
|
+
@contexts.delete_if { |c| c.name == name }
|
48
|
+
end
|
49
|
+
|
50
|
+
##
|
51
|
+
# Adds a command to the current context
|
52
|
+
def add_command(name, **options, &block)
|
53
|
+
raise ContextError, "Command #{name} contains invalid characters" if invalid_chars?(name)
|
54
|
+
|
55
|
+
name = name.downcase.to_sym
|
56
|
+
raise ContextError, "Context #{name} already exists in context #{self}" if context?(name)
|
57
|
+
raise ContextError, "Command #{name} already exists in context #{self}" if command?(name)
|
58
|
+
|
59
|
+
command = Command.new(name, @shell, self, block, **options)
|
60
|
+
@commands << command
|
61
|
+
command
|
62
|
+
end
|
63
|
+
|
64
|
+
##
|
65
|
+
# Removes a command from the current context
|
66
|
+
def remove_command(name)
|
67
|
+
name = name.downcase.to_sym
|
68
|
+
raise ContextError, "Command #{name} doesn't exists in context #{self}" unless command?(name)
|
69
|
+
|
70
|
+
@commands.delete_if { |c| c.match?(name) }
|
71
|
+
end
|
72
|
+
|
73
|
+
##
|
74
|
+
# Executes the command in the current context
|
75
|
+
def exec(shell, tokens)
|
76
|
+
path = tokens.first.split('/')
|
77
|
+
if path.empty?
|
78
|
+
shell.path.clear
|
79
|
+
return
|
80
|
+
end
|
81
|
+
|
82
|
+
path_prev = shell.path.clone
|
83
|
+
shell.path.clear if tokens.first.start_with?('/')
|
84
|
+
if path.length > 1
|
85
|
+
path[0..-2].each do |p|
|
86
|
+
if p == '..'
|
87
|
+
shell.path.pop
|
88
|
+
next
|
89
|
+
end
|
90
|
+
|
91
|
+
shell.path << p.to_sym unless p.empty?
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
if path.last == '..'
|
96
|
+
shell.path.pop
|
97
|
+
return
|
98
|
+
end
|
99
|
+
|
100
|
+
current = shell.root.context(*shell.path)
|
101
|
+
if current.nil?
|
102
|
+
shell.puts("Unrecognized command: #{tokens.first}")
|
103
|
+
shell.path = path_prev
|
104
|
+
return
|
105
|
+
end
|
106
|
+
|
107
|
+
current.contexts.each do |context|
|
108
|
+
next unless context.name.to_s == path.last
|
109
|
+
|
110
|
+
shell.path << context.name
|
111
|
+
return nil
|
112
|
+
end
|
113
|
+
|
114
|
+
commands = []
|
115
|
+
commands += current.commands
|
116
|
+
commands += shell.root.commands.select(&:global?)
|
117
|
+
commands.each do |command|
|
118
|
+
next unless command.match?(path.last)
|
119
|
+
|
120
|
+
command.exec(tokens)
|
121
|
+
shell.path = path_prev
|
122
|
+
return nil
|
123
|
+
end
|
124
|
+
|
125
|
+
shell.puts("Unrecognized command: #{tokens.first}")
|
126
|
+
shell.path = path_prev
|
127
|
+
end
|
128
|
+
|
129
|
+
##
|
130
|
+
# Returns a context by the path
|
131
|
+
def context(*path)
|
132
|
+
return self if path.empty?
|
133
|
+
|
134
|
+
path.map!(&:downcase)
|
135
|
+
path.map!(&:to_sym)
|
136
|
+
context = nil
|
137
|
+
current = self
|
138
|
+
path.each do |p|
|
139
|
+
context = current.contexts.detect { |c| c.name == p }
|
140
|
+
break if context.nil?
|
141
|
+
|
142
|
+
current = context
|
143
|
+
end
|
144
|
+
context
|
145
|
+
end
|
146
|
+
|
147
|
+
##
|
148
|
+
# Returns a command by the path
|
149
|
+
def command(*path)
|
150
|
+
return if path.empty?
|
151
|
+
|
152
|
+
context = context(*path[0..-2])
|
153
|
+
context.commands.detect { |c| c.match?(path.last) }
|
154
|
+
end
|
155
|
+
|
156
|
+
##
|
157
|
+
# Returns the string representation of the context
|
158
|
+
def to_s
|
159
|
+
@name.to_s
|
160
|
+
end
|
161
|
+
|
162
|
+
private
|
163
|
+
|
164
|
+
##
|
165
|
+
# Returns true if the context exists in the current context
|
166
|
+
def context?(name)
|
167
|
+
@contexts.any? { |c| c.name == name }
|
168
|
+
end
|
169
|
+
|
170
|
+
##
|
171
|
+
# Returns true if the command exists in the current context
|
172
|
+
def command?(name)
|
173
|
+
@commands.any? { |c| c.match?(name) }
|
174
|
+
end
|
175
|
+
|
176
|
+
##
|
177
|
+
# Returns true if the name contains invalid characters
|
178
|
+
def invalid_chars?(name)
|
179
|
+
name =~ /[#{INVALID_CHARS.join}]/
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
##
|
184
|
+
# Context error
|
185
|
+
class ContextError < StandardError; end
|
186
|
+
end
|
@@ -0,0 +1,286 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'readline'
|
4
|
+
require 'shellwords'
|
5
|
+
|
6
|
+
CONFIRM_LIST = %i[y n].freeze
|
7
|
+
|
8
|
+
module Sandbox
|
9
|
+
##
|
10
|
+
# Shell
|
11
|
+
class Shell
|
12
|
+
DEFAULT_PROMPT = '> '
|
13
|
+
DEFAULT_BANNER = 'Sandbox shell'
|
14
|
+
|
15
|
+
attr_reader :root
|
16
|
+
attr_accessor :prompt, :banner, :path
|
17
|
+
|
18
|
+
##
|
19
|
+
# Creates a new shell
|
20
|
+
def initialize(
|
21
|
+
input = $stdin,
|
22
|
+
output = $stdout,
|
23
|
+
prompt: DEFAULT_PROMPT,
|
24
|
+
banner: DEFAULT_BANNER,
|
25
|
+
history: true,
|
26
|
+
builtin_help: true,
|
27
|
+
builtin_quit: true,
|
28
|
+
builtin_path: true
|
29
|
+
)
|
30
|
+
@input = input
|
31
|
+
@output = output
|
32
|
+
@history = history
|
33
|
+
|
34
|
+
@prompt = prompt
|
35
|
+
@banner = banner
|
36
|
+
|
37
|
+
@root = Context.new(:root, self)
|
38
|
+
@path = []
|
39
|
+
@running = false
|
40
|
+
@reading = false
|
41
|
+
|
42
|
+
add_command_help if builtin_help
|
43
|
+
add_command_quit if builtin_quit
|
44
|
+
add_command_path if builtin_path
|
45
|
+
end
|
46
|
+
|
47
|
+
##
|
48
|
+
# Runs the shell
|
49
|
+
def run
|
50
|
+
puts(@banner)
|
51
|
+
Readline.completion_proc = proc { |line| completion_proc(line) }
|
52
|
+
@running = true
|
53
|
+
while @running
|
54
|
+
begin
|
55
|
+
line = readline("#{formatted_path}#{@prompt}", @history)
|
56
|
+
if line.nil?
|
57
|
+
puts
|
58
|
+
break
|
59
|
+
end
|
60
|
+
|
61
|
+
line.strip!
|
62
|
+
next if line.empty?
|
63
|
+
|
64
|
+
exec(line)
|
65
|
+
rescue ShellError => e
|
66
|
+
puts(e)
|
67
|
+
retry
|
68
|
+
rescue Interrupt
|
69
|
+
print("\e[0G\e[J")
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
##
|
75
|
+
# Executes command
|
76
|
+
def exec(line)
|
77
|
+
tokens = split_tokens(line)
|
78
|
+
@root.context(*@path).exec(self, tokens)
|
79
|
+
end
|
80
|
+
|
81
|
+
##
|
82
|
+
# Reads and returns a line
|
83
|
+
def readline(prompt, history)
|
84
|
+
@reading = true
|
85
|
+
line = Readline.readline(prompt, history)
|
86
|
+
Readline::HISTORY.pop if line&.strip&.empty?
|
87
|
+
Readline::HISTORY.pop if Readline::HISTORY.length >= 2 && Readline::HISTORY[-2] == line
|
88
|
+
@reading = false
|
89
|
+
line
|
90
|
+
end
|
91
|
+
|
92
|
+
##
|
93
|
+
# Stops the shell
|
94
|
+
def stop
|
95
|
+
@running = false
|
96
|
+
end
|
97
|
+
|
98
|
+
##
|
99
|
+
# Prints data
|
100
|
+
def print(data)
|
101
|
+
@output.print(data)
|
102
|
+
end
|
103
|
+
|
104
|
+
##
|
105
|
+
# Prints data with a line at the end
|
106
|
+
def puts(data = '')
|
107
|
+
@output.print("\e[0G\e[J") if @reading
|
108
|
+
@output.puts(data)
|
109
|
+
Readline.refresh_line if @reading
|
110
|
+
end
|
111
|
+
|
112
|
+
##
|
113
|
+
# Requests the confirmation
|
114
|
+
def confirm(prompt, default: :n)
|
115
|
+
list = CONFIRM_LIST.join('/')
|
116
|
+
raise ShellError, "Default should be #{list}" unless CONFIRM_LIST.include?(default)
|
117
|
+
|
118
|
+
prompt = "#{prompt} (#{list}) [#{default}] "
|
119
|
+
loop do
|
120
|
+
line = readline(prompt, @history)
|
121
|
+
|
122
|
+
if line.nil?
|
123
|
+
puts
|
124
|
+
return
|
125
|
+
end
|
126
|
+
|
127
|
+
line.strip!
|
128
|
+
line.downcase!
|
129
|
+
return default == CONFIRM_LIST.first if line.empty?
|
130
|
+
|
131
|
+
next unless CONFIRM_LIST.map(&:to_s).include?(line)
|
132
|
+
|
133
|
+
return line == CONFIRM_LIST.first.to_s
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
##
|
138
|
+
# Returns the current formatted path
|
139
|
+
def formatted_path
|
140
|
+
"/#{@path.join('/')}"
|
141
|
+
end
|
142
|
+
|
143
|
+
##
|
144
|
+
# Adds a new context to the root context
|
145
|
+
def add_context(name, **options)
|
146
|
+
@root.add_context(name, **options)
|
147
|
+
end
|
148
|
+
|
149
|
+
##
|
150
|
+
# Removes a context from the root context
|
151
|
+
def remove_context(name)
|
152
|
+
@root.remove_context(name)
|
153
|
+
end
|
154
|
+
|
155
|
+
##
|
156
|
+
# Adds a command to the root context
|
157
|
+
def add_command(name, **options, &block)
|
158
|
+
@root.add_command(name, **options, &block)
|
159
|
+
end
|
160
|
+
##
|
161
|
+
|
162
|
+
# Removes a command from the root context
|
163
|
+
def remove_command(name)
|
164
|
+
@root.remove_command(name)
|
165
|
+
end
|
166
|
+
|
167
|
+
##
|
168
|
+
# Returns a context by the path from the root context
|
169
|
+
def context(*path)
|
170
|
+
@root.context(*path)
|
171
|
+
end
|
172
|
+
|
173
|
+
##
|
174
|
+
# Returns a command by the path from the root context
|
175
|
+
def command(*path)
|
176
|
+
@root.command(*path)
|
177
|
+
end
|
178
|
+
|
179
|
+
private
|
180
|
+
|
181
|
+
##
|
182
|
+
# Returns an array of tokens from the line
|
183
|
+
def split_tokens(line)
|
184
|
+
tokens = line.shellsplit
|
185
|
+
tokens.first&.downcase!
|
186
|
+
tokens
|
187
|
+
rescue ArgumentError => e
|
188
|
+
raise ShellError, e
|
189
|
+
end
|
190
|
+
|
191
|
+
##
|
192
|
+
# Readline completion proc
|
193
|
+
def completion_proc(line)
|
194
|
+
tokens = split_tokens(Readline.line_buffer)
|
195
|
+
|
196
|
+
context = @root.context(*@path)
|
197
|
+
commands = []
|
198
|
+
commands += context.contexts
|
199
|
+
commands += context.commands
|
200
|
+
commands += @root.commands.select(&:global?) unless @path.empty?
|
201
|
+
|
202
|
+
command = commands.detect { |c| c.instance_of?(Command) && c.match?(tokens.first) }
|
203
|
+
list = commands.map(&:name)
|
204
|
+
list = command.completion_proc&.call(line, tokens, self, context, command) unless command.nil?
|
205
|
+
|
206
|
+
list&.grep(/^#{Regexp.escape(line)}/)
|
207
|
+
end
|
208
|
+
|
209
|
+
# Built-in help command
|
210
|
+
def add_command_help
|
211
|
+
@root.add_command(
|
212
|
+
:help,
|
213
|
+
aliases: ['?'],
|
214
|
+
description: 'This help',
|
215
|
+
global: true
|
216
|
+
) do |tokens, shell|
|
217
|
+
list = []
|
218
|
+
list += @root.commands.select(&:global?) unless @path.empty?
|
219
|
+
list += @root.context(*@path).commands
|
220
|
+
list.sort! { |a, b| a.name <=> b.name }
|
221
|
+
list = @root.context(*@path).contexts.sort { |a, b| a.name <=> b.name } + list
|
222
|
+
|
223
|
+
unless tokens[1].nil?
|
224
|
+
cmd = list.detect { |c| c.instance_of?(Command) && c.match?(tokens[1]) }
|
225
|
+
if cmd.nil?
|
226
|
+
shell.puts("Unknown command #{tokens[1]}")
|
227
|
+
next
|
228
|
+
end
|
229
|
+
|
230
|
+
shell.puts(cmd.description) unless cmd.description.nil?
|
231
|
+
shell.print(' ')
|
232
|
+
cmd.print_usage
|
233
|
+
next
|
234
|
+
end
|
235
|
+
|
236
|
+
list.each do |c|
|
237
|
+
if c.instance_of?(Context)
|
238
|
+
shell.puts(
|
239
|
+
format(
|
240
|
+
' %<name>-25s %<description>s',
|
241
|
+
name: "[#{c.name}]",
|
242
|
+
description: c.description
|
243
|
+
)
|
244
|
+
)
|
245
|
+
next
|
246
|
+
end
|
247
|
+
|
248
|
+
shell.puts(
|
249
|
+
format(
|
250
|
+
' %<name>-25s %<description>s',
|
251
|
+
name: "#{c.name} #{c.params.keys.join(' ')}",
|
252
|
+
description: c.description
|
253
|
+
)
|
254
|
+
)
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
# Built-in quit command
|
260
|
+
def add_command_quit
|
261
|
+
@root.add_command(
|
262
|
+
:quit,
|
263
|
+
aliases: [:exit],
|
264
|
+
description: 'Quit',
|
265
|
+
global: true
|
266
|
+
) do |_tokens, shell|
|
267
|
+
shell.stop
|
268
|
+
end
|
269
|
+
end
|
270
|
+
|
271
|
+
# Built-in path command
|
272
|
+
def add_command_path
|
273
|
+
@root.add_command(
|
274
|
+
:path,
|
275
|
+
description: 'Show path',
|
276
|
+
global: true
|
277
|
+
) do |_tokens, shell|
|
278
|
+
shell.puts(shell.formatted_path)
|
279
|
+
end
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
##
|
284
|
+
# Shell error
|
285
|
+
class ShellError < StandardError; end
|
286
|
+
end
|
data/lib/sandbox.rb
ADDED
metadata
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sandbox-ruby
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: '0.1'
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- anim
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-05-12 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description:
|
14
|
+
email: me@telpart.ru
|
15
|
+
executables: []
|
16
|
+
extensions: []
|
17
|
+
extra_rdoc_files: []
|
18
|
+
files:
|
19
|
+
- lib/sandbox.rb
|
20
|
+
- lib/sandbox/command.rb
|
21
|
+
- lib/sandbox/context.rb
|
22
|
+
- lib/sandbox/shell.rb
|
23
|
+
- lib/sandbox/version.rb
|
24
|
+
homepage: https://github.com/animotto/sandbox-ruby
|
25
|
+
licenses:
|
26
|
+
- MIT
|
27
|
+
metadata: {}
|
28
|
+
post_install_message:
|
29
|
+
rdoc_options: []
|
30
|
+
require_paths:
|
31
|
+
- lib
|
32
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
33
|
+
requirements:
|
34
|
+
- - ">="
|
35
|
+
- !ruby/object:Gem::Version
|
36
|
+
version: '2.7'
|
37
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
38
|
+
requirements:
|
39
|
+
- - ">="
|
40
|
+
- !ruby/object:Gem::Version
|
41
|
+
version: '0'
|
42
|
+
requirements: []
|
43
|
+
rubygems_version: 3.1.2
|
44
|
+
signing_key:
|
45
|
+
specification_version: 4
|
46
|
+
summary: Sandbox shell library for Ruby
|
47
|
+
test_files: []
|