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 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: []