toys-core 0.3.2

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.
@@ -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