toys-core 0.3.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,39 @@
1
+ # Copyright 2018 Daniel Azuma
2
+ #
3
+ # All rights reserved.
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are met:
7
+ #
8
+ # * Redistributions of source code must retain the above copyright notice,
9
+ # this list of conditions and the following disclaimer.
10
+ # * Redistributions in binary form must reproduce the above copyright notice,
11
+ # this list of conditions and the following disclaimer in the documentation
12
+ # and/or other materials provided with the distribution.
13
+ # * Neither the name of the copyright holder, nor the names of any other
14
+ # contributors to this software, may be used to endorse or promote products
15
+ # derived from this software without specific prior written permission.
16
+ #
17
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
21
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
22
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
23
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27
+ # POSSIBILITY OF SUCH DAMAGE.
28
+ ;
29
+
30
+ require "fileutils"
31
+
32
+ module Toys
33
+ module Helpers
34
+ ##
35
+ # File system utilities. See the "fileutils" standard library.
36
+ #
37
+ FileUtils = ::FileUtils
38
+ end
39
+ end
@@ -0,0 +1,381 @@
1
+ # Copyright 2018 Daniel Azuma
2
+ #
3
+ # All rights reserved.
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are met:
7
+ #
8
+ # * Redistributions of source code must retain the above copyright notice,
9
+ # this list of conditions and the following disclaimer.
10
+ # * Redistributions in binary form must reproduce the above copyright notice,
11
+ # this list of conditions and the following disclaimer in the documentation
12
+ # and/or other materials provided with the distribution.
13
+ # * Neither the name of the copyright holder, nor the names of any other
14
+ # contributors to this software, may be used to endorse or promote products
15
+ # derived from this software without specific prior written permission.
16
+ #
17
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
21
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
22
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
23
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27
+ # POSSIBILITY OF SUCH DAMAGE.
28
+ ;
29
+
30
+ module Toys
31
+ ##
32
+ # The Loader service loads tools from configuration files, and finds the
33
+ # appropriate tool given a set of command line arguments.
34
+ #
35
+ class Loader
36
+ ##
37
+ # Create a Loader
38
+ #
39
+ # @param [String,nil] index_file_name A file with this name that appears
40
+ # in any configuration directory (not just a toplevel directory) is
41
+ # loaded first as a standalone configuration file. If not provided,
42
+ # standalone configuration files are disabled.
43
+ # @param [String,nil] preload_file_name A file with this name that appears
44
+ # in any configuration directory (not just a toplevel directory) is
45
+ # loaded before any configuration files. It is not treated as a
46
+ # configuration file in that the configuration DSL is not honored. You
47
+ # may use such a file to define auxiliary Ruby modules and classes that
48
+ # used by the tools defined in that directory.
49
+ # @param [Array] middleware_stack An array of middleware that will be used
50
+ # by default for all tools loaded by this loader.
51
+ #
52
+ def initialize(index_file_name: nil, preload_file_name: nil, middleware_stack: [])
53
+ if index_file_name && ::File.extname(index_file_name) != ".rb"
54
+ raise LoaderError, "Illegal index file name #{index_file_name.inspect}"
55
+ end
56
+ if preload_file_name && ::File.extname(preload_file_name) != ".rb"
57
+ raise LoaderError, "Illegal preload file name #{preload_file_name.inspect}"
58
+ end
59
+ @index_file_name = index_file_name
60
+ @preload_file_name = preload_file_name
61
+ @middleware_stack = middleware_stack
62
+ @load_worklist = []
63
+ @tools = {}
64
+ @max_priority = @min_priority = 0
65
+ end
66
+
67
+ ##
68
+ # Add a configuration file/directory to the loader.
69
+ #
70
+ # @param [String,Array<String>] path One or more paths to add.
71
+ # @param [Boolean] high_priority If true, add this path at the top of the
72
+ # priority list. Defaults to false, indicating the new path should be
73
+ # at the bottom of the priority list.
74
+ #
75
+ def add_path(path, high_priority: false)
76
+ paths = Array(path)
77
+ priority = high_priority ? (@max_priority += 1) : (@min_priority -= 1)
78
+ paths.each do |p|
79
+ @load_worklist << [check_path(p), [], priority]
80
+ end
81
+ self
82
+ end
83
+
84
+ ##
85
+ # Given a list of command line arguments, find the appropriate tool to
86
+ # handle the command, loading it from the configuration if necessary.
87
+ # This always returns a tool. If the specific tool path is not defined and
88
+ # cannot be found in any configuration, it returns the nearest group that
89
+ # _would_ contain that tool, up to the root tool.
90
+ #
91
+ # @param [String] args Command line arguments
92
+ # @return [Toys::Tool]
93
+ #
94
+ def lookup(args)
95
+ orig_prefix = args.take_while { |arg| !arg.start_with?("-") }
96
+ cur_prefix = orig_prefix.dup
97
+ loop do
98
+ load_for_prefix(cur_prefix)
99
+ p = orig_prefix.dup
100
+ while p.length >= cur_prefix.length
101
+ tool = get_tool(p, [])
102
+ return tool if tool
103
+ p.pop
104
+ end
105
+ break unless cur_prefix.pop
106
+ end
107
+ get_or_create_tool([])
108
+ end
109
+
110
+ ##
111
+ # Returns a list of subtools for the given path, loading from the
112
+ # configuration if necessary.
113
+ #
114
+ # @param [Array<String>] words The name of the parent tool
115
+ # @param [Boolean] recursive If true, return all subtools recursively
116
+ # rather than just the immediate children (the default)
117
+ # @return [Array<Toys::Tool,Tool::Alias>]
118
+ #
119
+ def list_subtools(words, recursive: false)
120
+ load_for_prefix(words)
121
+ found_tools = []
122
+ len = words.length
123
+ @tools.each do |n, tp|
124
+ next if n.empty?
125
+ if recursive
126
+ next if n.length <= len || n.slice(0, len) != words
127
+ else
128
+ next unless n.slice(0..-2) == words
129
+ end
130
+ found_tools << tp.first
131
+ end
132
+ sort_tools_by_name(found_tools)
133
+ end
134
+
135
+ ##
136
+ # Execute the tool given by the given arguments, in the given context.
137
+ #
138
+ # @param [Toys::Context::Base] context_base The context in which to
139
+ # execute.
140
+ # @param [String] args Command line arguments
141
+ # @param [Integer] verbosity Starting verbosity. Defaults to 0.
142
+ # @return [Integer] The exit code
143
+ #
144
+ # @private
145
+ #
146
+ def execute(context_base, args, verbosity: 0)
147
+ tool = lookup(args)
148
+ tool.execute(context_base, args.slice(tool.full_name.length..-1), verbosity: verbosity)
149
+ end
150
+
151
+ ##
152
+ # Returns a tool specified by the given words, with the given priority.
153
+ # Does not do any loading. If the tool is not present, creates it.
154
+ #
155
+ # @param [Array<String>] words The name of the tool.
156
+ # @param [Integer,nil] priority The priority of the request.
157
+ # @return [Toys::Tool,Toys::Alias,nil] The tool or alias, or `nil` if the
158
+ # given priority is insufficient for modification
159
+ #
160
+ # @private
161
+ #
162
+ def get_or_create_tool(words, priority: nil)
163
+ if @tools.key?(words)
164
+ tool, tool_priority = @tools[words]
165
+ if !priority || !tool_priority || tool_priority == priority
166
+ if priority && tool.is_a?(Alias)
167
+ raise LoaderError, "Cannot modify #{@words.inspect} because it is already an alias"
168
+ end
169
+ return tool
170
+ end
171
+ return nil if tool_priority > priority
172
+ end
173
+ get_or_create_tool(words[0..-2]) unless words.empty?
174
+ tool = Tool.new(words)
175
+ tool.middleware_stack.concat(Middleware.resolve_stack(@middleware_stack))
176
+ @tools[words] = [tool, priority]
177
+ tool
178
+ end
179
+
180
+ ##
181
+ # Sets the given name as an alias to the given target.
182
+ #
183
+ # @param [Array<String>] words The alias name
184
+ # @param [Array<String>] target The alias target name
185
+ # @param [Integer] priority The priority of the request
186
+ #
187
+ # @return [Toys::Alias] The alias created
188
+ #
189
+ # @private
190
+ #
191
+ def make_alias(words, target, priority)
192
+ if @tools.key?(words)
193
+ tool_priority = @tools[words].last
194
+ if tool_priority
195
+ if tool_priority == priority
196
+ raise LoaderError, "Cannot make #{words.inspect} an alias because it is already defined"
197
+ elsif tool_priority > priority
198
+ return nil
199
+ end
200
+ end
201
+ end
202
+ a = Alias.new(words, target)
203
+ @tools[words] = [a, priority]
204
+ a
205
+ end
206
+
207
+ ##
208
+ # Adds a tool directly to the loader.
209
+ # This should be used only for testing, as it overrides normal priority
210
+ # checking.
211
+ #
212
+ # @param [Toys::Tool] tool Tool to add.
213
+ # @param [Integer,nil] priority Priority for the tool.
214
+ #
215
+ # @private
216
+ #
217
+ def put_tool!(tool, priority = nil)
218
+ @tools[tool.full_name] = [tool, priority]
219
+ self
220
+ end
221
+
222
+ ##
223
+ # Returns true if the given tool name currently exists in the loader.
224
+ # Does not load the tool if not found.
225
+ #
226
+ # @param [Array<String>] words The name of the tool.
227
+ # @return [Boolean]
228
+ #
229
+ # @private
230
+ #
231
+ def tool_defined?(words)
232
+ @tools.key?(words)
233
+ end
234
+
235
+ ##
236
+ # Returns a tool given a name. Resolves any aliases.
237
+ #
238
+ # @param [Array<String>] words Name of the tool
239
+ # @param [Array<Array<String>>] looked_up List of names that have already
240
+ # been traversed during alias resolution. Used to detect circular
241
+ # alias references.
242
+ # @return [Toys::Tool,nil] The tool, or `nil` if not found
243
+ #
244
+ # @private
245
+ #
246
+ def get_tool(words, looked_up = [])
247
+ return nil unless @tools.key?(words)
248
+ result = @tools[words].first
249
+ if result.is_a?(Alias)
250
+ words = result.target_name
251
+ if looked_up.include?(words)
252
+ raise LoaderError, "Circular alias references: #{looked_up.inspect}"
253
+ end
254
+ looked_up << words
255
+ get_tool(words, looked_up)
256
+ else
257
+ result
258
+ end
259
+ end
260
+
261
+ ##
262
+ # Load configuration from the given path.
263
+ #
264
+ # @private
265
+ #
266
+ def include_path(path, words, remaining_words, priority)
267
+ handle_path(check_path(path), words, remaining_words, priority)
268
+ end
269
+
270
+ ##
271
+ # Determine the next setting for remaining_words, given a word.
272
+ #
273
+ # @private
274
+ #
275
+ def self.next_remaining_words(remaining_words, word)
276
+ if remaining_words.nil?
277
+ nil
278
+ elsif remaining_words.empty?
279
+ remaining_words
280
+ elsif remaining_words.first == word
281
+ remaining_words.slice(1..-1)
282
+ end
283
+ end
284
+
285
+ private
286
+
287
+ def load_for_prefix(prefix)
288
+ cur_worklist = @load_worklist
289
+ @load_worklist = []
290
+ cur_worklist.each do |path, words, priority|
291
+ handle_path(path, words, calc_remaining_words(prefix, words), priority)
292
+ end
293
+ end
294
+
295
+ def handle_path(path, words, remaining_words, priority)
296
+ if remaining_words
297
+ load_path(path, words, remaining_words, priority)
298
+ else
299
+ @load_worklist << [path, words, priority]
300
+ end
301
+ end
302
+
303
+ def load_path(path, words, remaining_words, priority)
304
+ if ::File.extname(path) == ".rb"
305
+ ConfigDSL.evaluate(words, remaining_words, priority, self, path, ::IO.read(path))
306
+ else
307
+ require_preload_in(path)
308
+ load_index_in(path, words, remaining_words, priority)
309
+ ::Dir.entries(path).each do |child|
310
+ load_child_in(path, child, words, remaining_words, priority)
311
+ end
312
+ end
313
+ end
314
+
315
+ def require_preload_in(path)
316
+ return unless @preload_file_name
317
+ preload_path = ::File.join(path, @preload_file_name)
318
+ preload_path = check_path(preload_path, type: :file, lenient: true)
319
+ require preload_path if preload_path
320
+ end
321
+
322
+ def load_index_in(path, words, remaining_words, priority)
323
+ return unless @index_file_name
324
+ index_path = ::File.join(path, @index_file_name)
325
+ index_path = check_path(index_path, type: :file, lenient: true)
326
+ load_path(index_path, words, remaining_words, priority) if index_path
327
+ end
328
+
329
+ def load_child_in(path, child, words, remaining_words, priority)
330
+ return if child.start_with?(".")
331
+ return if [@preload_file_name, @index_file_name].include?(child)
332
+ child_path = check_path(::File.join(path, child))
333
+ child_word = ::File.basename(child, ".rb")
334
+ next_words = words + [child_word]
335
+ next_remaining = Loader.next_remaining_words(remaining_words, child_word)
336
+ handle_path(child_path, next_words, next_remaining, priority)
337
+ end
338
+
339
+ def check_path(path, lenient: false, type: nil)
340
+ path = ::File.expand_path(path)
341
+ type ||= ::File.extname(path) == ".rb" ? :file : :dir
342
+ case type
343
+ when :file
344
+ if ::File.directory?(path) || !::File.readable?(path)
345
+ return nil if lenient
346
+ raise LoaderError, "Cannot read file #{path}"
347
+ end
348
+ when :dir
349
+ if !::File.directory?(path) || !::File.readable?(path)
350
+ return nil if lenient
351
+ raise LoaderError, "Cannot read directory #{path}"
352
+ end
353
+ else
354
+ raise ArgumentError, "Illegal type #{type}"
355
+ end
356
+ path
357
+ end
358
+
359
+ def sort_tools_by_name(tools)
360
+ tools.sort do |a, b|
361
+ a = a.full_name
362
+ b = b.full_name
363
+ while !a.empty? && !b.empty? && a.first == b.first
364
+ a = a.slice(1..-1)
365
+ b = b.slice(1..-1)
366
+ end
367
+ a.first.to_s <=> b.first.to_s
368
+ end
369
+ end
370
+
371
+ def calc_remaining_words(words1, words2)
372
+ index = 0
373
+ lengths = [words1.length, words2.length]
374
+ loop do
375
+ return words1.slice(index..-1) if lengths.include?(index)
376
+ return nil if words1[index] != words2[index]
377
+ index += 1
378
+ end
379
+ end
380
+ end
381
+ end
@@ -0,0 +1,124 @@
1
+ # Copyright 2018 Daniel Azuma
2
+ #
3
+ # All rights reserved.
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are met:
7
+ #
8
+ # * Redistributions of source code must retain the above copyright notice,
9
+ # this list of conditions and the following disclaimer.
10
+ # * Redistributions in binary form must reproduce the above copyright notice,
11
+ # this list of conditions and the following disclaimer in the documentation
12
+ # and/or other materials provided with the distribution.
13
+ # * Neither the name of the copyright holder, nor the names of any other
14
+ # contributors to this software, may be used to endorse or promote products
15
+ # derived from this software without specific prior written permission.
16
+ #
17
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
21
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
22
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
23
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27
+ # POSSIBILITY OF SUCH DAMAGE.
28
+ ;
29
+
30
+ require "toys/utils/module_lookup"
31
+
32
+ module Toys
33
+ ##
34
+ # Middleware lets you define common behavior across many tools.
35
+ #
36
+ module Middleware
37
+ class << self
38
+ ##
39
+ # Return a well-known middleware class by name.
40
+ #
41
+ # Currently recognized middleware names are:
42
+ #
43
+ # * `:add_verbosity_switches` : Adds switches for affecting verbosity.
44
+ # * `:handle_usage_errors` : Displays the usage error if one occurs.
45
+ # * `:set_default_descriptions` : Sets default descriptions for tools
46
+ # that do not have them set explicitly.
47
+ # * `:show_usage` : Teaches tools to print their usage documentation.
48
+ # * `:show_version` : Teaches tools to print their version.
49
+ #
50
+ # @param [String,Symbol] name Name of the middleware class to return
51
+ # @return [Class,nil] The class, or `nil` if not found
52
+ #
53
+ def lookup(name)
54
+ Utils::ModuleLookup.lookup(:middleware, name)
55
+ end
56
+
57
+ ##
58
+ # Resolves a single middleware. You may pass an instance already
59
+ # constructed, a middleware class, the name of a well-known middleware
60
+ # class, or an array where the first element is the lookup name or class,
61
+ # and subsequent elements are arguments to be passed to the constructor.
62
+ #
63
+ # @param [String,Symbol,Array,Object] input The middleware spec
64
+ # @return [Object] Constructed middleware
65
+ #
66
+ def resolve(input)
67
+ input = Array(input)
68
+ raise "No middleware found" if input.empty?
69
+ cls = input.first
70
+ args = input[1..-1]
71
+ if cls.is_a?(::String) || cls.is_a?(::Symbol)
72
+ cls = lookup(cls)
73
+ end
74
+ if cls.is_a?(::Class)
75
+ cls.new(*args)
76
+ else
77
+ raise "Unrecognized middleware class #{cls.class}" unless args.empty?
78
+ cls
79
+ end
80
+ end
81
+
82
+ ##
83
+ # Resolves an array of middleware specs. See {Toys::Middleware.resolve}.
84
+ #
85
+ # @param [Array] input An array of middleware specs
86
+ # @return [Array] An array of constructed middleware
87
+ #
88
+ def resolve_stack(input)
89
+ input.map { |e| resolve(e) }
90
+ end
91
+
92
+ ##
93
+ # Resolves a typical switches specification. Used often in middleware.
94
+ #
95
+ # You may provide any of the following for the `switches` parameter:
96
+ # * A string, which becomes the single switch
97
+ # * An array of strings
98
+ # * The value `false` or `nil` which resolves to no switches
99
+ # * The value `true` or `:default` which resolves to the given defaults
100
+ # * A proc that takes a tool as argument and returns any of the above.
101
+ #
102
+ # Always returns an array of switch strings, even if empty.
103
+ #
104
+ # @param [Boolean,String,Array<String>,Proc] switches Switch spec
105
+ # @param [Toys::Tool] tool The tool
106
+ # @param [Array<String>] defaults The defaults to use for `true`.
107
+ # @return [Array<String>] An array of switches
108
+ #
109
+ def resolve_switches_spec(switches, tool, defaults)
110
+ switches = switches.call(tool) if switches.respond_to?(:call)
111
+ case switches
112
+ when true, :default
113
+ Array(defaults)
114
+ when ::String
115
+ [switches]
116
+ when ::Array
117
+ switches
118
+ else
119
+ []
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end