sandbox-ruby 0.1

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sandbox
4
+ VERSION = '0.1'
5
+ end
data/lib/sandbox.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sandbox/version'
4
+ require 'sandbox/shell'
5
+ require 'sandbox/context'
6
+ require 'sandbox/command'
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: []