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,126 @@
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
+ module Templates
32
+ ##
33
+ # A template for tools that run minitest
34
+ #
35
+ class Minitest
36
+ include Template
37
+
38
+ ##
39
+ # Default tool name
40
+ # @return [String]
41
+ #
42
+ DEFAULT_TOOL_NAME = "test".freeze
43
+
44
+ ##
45
+ # Default set of library paths
46
+ # @return [Array<String>]
47
+ #
48
+ DEFAULT_LIBS = ["lib"].freeze
49
+
50
+ ##
51
+ # Default set of test file globs
52
+ # @return [Array<String>]
53
+ #
54
+ DEFAULT_FILES = ["test/**/test*.rb"].freeze
55
+
56
+ ##
57
+ # Create the template settings for the Minitest template.
58
+ #
59
+ # @param [String] name Name of the tool to create. Defaults to
60
+ # {DEFAULT_TOOL_NAME}.
61
+ # @param [Array<String>] libs An array of library paths to add to the
62
+ # ruby require path. Defaults to {DEFAULT_LIBS}.
63
+ # @param [Array<String>] files An array of globs indicating the test
64
+ # files to load. Defaults to {DEFAULT_FILES}.
65
+ # @param [Boolean] warnings If true, runs tests with Ruby warnings.
66
+ # Defaults to true.
67
+ #
68
+ def initialize(name: DEFAULT_TOOL_NAME,
69
+ libs: DEFAULT_LIBS,
70
+ files: DEFAULT_FILES,
71
+ warnings: true)
72
+ @name = name
73
+ @libs = libs
74
+ @files = files
75
+ @warnings = warnings
76
+ end
77
+
78
+ attr_accessor :name
79
+ attr_accessor :libs
80
+ attr_accessor :files
81
+ attr_accessor :warnings
82
+
83
+ to_expand do |template|
84
+ tool(template.name) do
85
+ desc "Run minitest on the current project."
86
+
87
+ use :exec
88
+
89
+ switch(
90
+ :warnings, "-w", "--[no-]warnings",
91
+ default: template.warnings,
92
+ doc: "Turn on Ruby warnings (defaults to #{template.warnings})"
93
+ )
94
+ remaining_args(:tests, doc: "Paths to the tests to run (defaults to all tests)")
95
+
96
+ execute do
97
+ ruby_args = []
98
+ unless template.libs.empty?
99
+ lib_path = template.libs.join(::File::PATH_SEPARATOR)
100
+ ruby_args << "-I#{lib_path}"
101
+ end
102
+ ruby_args << "-w" if self[:warnings]
103
+
104
+ tests = self[:tests]
105
+ if tests.empty?
106
+ Array(template.files).each do |pattern|
107
+ tests.concat(::Dir.glob(pattern))
108
+ end
109
+ tests.uniq!
110
+ end
111
+
112
+ result = ruby(ruby_args, in_from: :controller) do |controller|
113
+ tests.each do |file|
114
+ controller.in.puts("load '#{file}'")
115
+ end
116
+ end
117
+ if result.error?
118
+ logger.error("Minitest failed!")
119
+ exit(result.exit_code)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,86 @@
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
+ module Templates
32
+ ##
33
+ # A template for tools that run rubocop
34
+ #
35
+ class Rubocop
36
+ include Template
37
+
38
+ ##
39
+ # Default tool name
40
+ # @return [String]
41
+ #
42
+ DEFAULT_TOOL_NAME = "rubocop".freeze
43
+
44
+ ##
45
+ # Create the template settings for the Rubocop template.
46
+ #
47
+ # @param [String] name Name of the tool to create. Defaults to
48
+ # {DEFAULT_TOOL_NAME}.
49
+ # @param [Boolean] fail_on_error If true, exits with a nonzero code if
50
+ # Rubocop fails. Defaults to true.
51
+ # @param [Array<String>] options Additional options passed to the Rubocop
52
+ # CLI.
53
+ #
54
+ def initialize(name: DEFAULT_TOOL_NAME,
55
+ fail_on_error: true,
56
+ options: [])
57
+ @name = name
58
+ @fail_on_error = fail_on_error
59
+ @options = options
60
+ end
61
+
62
+ attr_accessor :name
63
+ attr_accessor :fail_on_error
64
+ attr_accessor :options
65
+
66
+ to_expand do |template|
67
+ tool(template.name) do
68
+ desc "Run rubocop on the current project."
69
+
70
+ use :exec
71
+
72
+ execute do
73
+ require "rubocop"
74
+ cli = ::RuboCop::CLI.new
75
+ logger.info "Running RuboCop..."
76
+ result = cli.run(template.options)
77
+ if result.nonzero?
78
+ logger.error "RuboCop failed!"
79
+ exit(1) if template.fail_on_error
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,101 @@
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
+ module Templates
32
+ ##
33
+ # A template for tools that run yardoc
34
+ #
35
+ class Yardoc
36
+ include Template
37
+
38
+ ##
39
+ # Default tool name
40
+ # @return [String]
41
+ #
42
+ DEFAULT_TOOL_NAME = "yardoc".freeze
43
+
44
+ ##
45
+ # Create the template settings for the Yardoc template.
46
+ #
47
+ # @param [String] name Name of the tool to create. Defaults to
48
+ # {DEFAULT_TOOL_NAME}.
49
+ # @param [Array<String>] files An array of globs indicating the files
50
+ # to document.
51
+ # @param [Array<String>] options Additional options passed to YARD
52
+ # @param [Array<String>] stats_options Additional options passed to YARD
53
+ # stats
54
+ #
55
+ def initialize(name: DEFAULT_TOOL_NAME,
56
+ files: [],
57
+ options: [],
58
+ stats_options: [])
59
+ @name = name
60
+ @files = files
61
+ @options = options
62
+ @stats_options = stats_options
63
+ end
64
+
65
+ attr_accessor :name
66
+ attr_accessor :files
67
+ attr_accessor :options
68
+ attr_accessor :stats_options
69
+
70
+ to_expand do |template|
71
+ tool(template.name) do
72
+ desc "Run yardoc on the current project."
73
+
74
+ use :exec
75
+
76
+ execute do
77
+ require "yard"
78
+ files = []
79
+ patterns = Array(template.files)
80
+ patterns = ["lib/**/*.rb"] if patterns.empty?
81
+ patterns.each do |pattern|
82
+ files.concat(::Dir.glob(pattern))
83
+ end
84
+ files.uniq!
85
+
86
+ unless template.stats_options.empty?
87
+ template.options << "--no-stats"
88
+ template.stats_options << "--use-cache"
89
+ end
90
+
91
+ yardoc = ::YARD::CLI::Yardoc.new
92
+ yardoc.run(*(template.options + files))
93
+ unless template.stats_options.empty?
94
+ ::YARD::CLI::Stats.run(*template.stats_options)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,749 @@
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 "optparse"
31
+
32
+ module Toys
33
+ ##
34
+ # A Tool is a single command that can be invoked using Toys.
35
+ # It has a name, a series of one or more words that you use to identify
36
+ # the tool on the command line. It also has a set of formal switches and
37
+ # command line arguments supported, and a block that gets run when the
38
+ # tool is executed.
39
+ #
40
+ class Tool
41
+ ##
42
+ # Create a new tool.
43
+ #
44
+ # @param [Array<String>] full_name The name of the tool
45
+ #
46
+ def initialize(full_name)
47
+ @full_name = full_name.dup.freeze
48
+ @middleware_stack = []
49
+
50
+ @definition_path = nil
51
+ @definition_finished = false
52
+
53
+ @desc = nil
54
+ @long_desc = nil
55
+
56
+ @default_data = {}
57
+ @switch_definitions = []
58
+ @required_arg_definitions = []
59
+ @optional_arg_definitions = []
60
+ @remaining_args_definition = nil
61
+
62
+ @helpers = {}
63
+ @modules = []
64
+ @executor = nil
65
+ end
66
+
67
+ ##
68
+ # Return the name of the tool as an array of strings.
69
+ # This array may not be modified.
70
+ # @return [Array<String>]
71
+ #
72
+ attr_reader :full_name
73
+
74
+ ##
75
+ # Return a list of all defined switches.
76
+ # @return [Array<Toys::Tool::SwitchDefinition>]
77
+ #
78
+ attr_reader :switch_definitions
79
+
80
+ ##
81
+ # Return a list of all defined required positional arguments.
82
+ # @return [Array<Toys::Tool::ArgDefinition>]
83
+ #
84
+ attr_reader :required_arg_definitions
85
+
86
+ ##
87
+ # Return a list of all defined optional positional arguments.
88
+ # @return [Array<Toys::Tool::ArgDefinition>]
89
+ #
90
+ attr_reader :optional_arg_definitions
91
+
92
+ ##
93
+ # Return the remaining arguments specification, or `nil` if remaining
94
+ # arguments are currently not supported by this tool.
95
+ # @return [Toys::Tool::ArgDefinition,nil]
96
+ #
97
+ attr_reader :remaining_args_definition
98
+
99
+ ##
100
+ # Return the default argument data.
101
+ # @return [Hash]
102
+ #
103
+ attr_reader :default_data
104
+
105
+ ##
106
+ # Return a list of modules that will be available during execution.
107
+ # @return [Array<Module>]
108
+ #
109
+ attr_reader :modules
110
+
111
+ ##
112
+ # Return a list of helper methods that will be available during execution.
113
+ # @return [Hash{Symbol => Proc}]
114
+ #
115
+ attr_reader :helpers
116
+
117
+ ##
118
+ # Return the executor block, or `nil` if not present.
119
+ # @return [Proc,nil]
120
+ #
121
+ attr_reader :executor
122
+
123
+ ##
124
+ # Returns the middleware stack
125
+ # @return [Array<Object>]
126
+ #
127
+ attr_reader :middleware_stack
128
+
129
+ ##
130
+ # Returns the path to the file that contains the definition of this tool.
131
+ # @return [String]
132
+ #
133
+ attr_reader :definition_path
134
+
135
+ ##
136
+ # Returns the local name of this tool.
137
+ # @return [String]
138
+ #
139
+ def simple_name
140
+ full_name.last
141
+ end
142
+
143
+ ##
144
+ # Returns a displayable name of this tool, generally the full name
145
+ # delimited by spaces.
146
+ # @return [String]
147
+ #
148
+ def display_name
149
+ full_name.join(" ")
150
+ end
151
+
152
+ ##
153
+ # Returns true if this tool is a root tool.
154
+ # @return [Boolean]
155
+ #
156
+ def root?
157
+ full_name.empty?
158
+ end
159
+
160
+ ##
161
+ # Returns true if this tool has an executor defined.
162
+ # @return [Boolean]
163
+ #
164
+ def includes_executor?
165
+ executor.is_a?(::Proc)
166
+ end
167
+
168
+ ##
169
+ # Returns the effective short description for this tool. This will be
170
+ # displayed when this tool is listed in a command list.
171
+ # @return [String]
172
+ #
173
+ def effective_desc
174
+ @desc || ""
175
+ end
176
+
177
+ ##
178
+ # Returns the effective long description for this tool. This will be
179
+ # displayed as part of the usage for this particular tool.
180
+ # @return [String]
181
+ #
182
+ def effective_long_desc
183
+ @long_desc || @desc || ""
184
+ end
185
+
186
+ ##
187
+ # Returns true if there is a specific description set for this tool.
188
+ # @return [Boolean]
189
+ #
190
+ def includes_description?
191
+ !@long_desc.nil? || !@desc.nil?
192
+ end
193
+
194
+ ##
195
+ # Returns true if at least one switch or positional argument is defined
196
+ # for this tool.
197
+ # @return [Boolean]
198
+ #
199
+ def includes_arguments?
200
+ !default_data.empty? || !switch_definitions.empty? ||
201
+ !required_arg_definitions.empty? || !optional_arg_definitions.empty? ||
202
+ !remaining_args_definition.nil?
203
+ end
204
+
205
+ ##
206
+ # Returns true if at least one helper method or module is added to this
207
+ # tool.
208
+ # @return [Boolean]
209
+ #
210
+ def includes_helpers?
211
+ !helpers.empty? || !modules.empty?
212
+ end
213
+
214
+ ##
215
+ # Returns true if this tool has any definition information.
216
+ # @return [Boolean]
217
+ #
218
+ def includes_definition?
219
+ includes_arguments? || includes_executor? || includes_helpers?
220
+ end
221
+
222
+ ##
223
+ # Returns a list of switch flags used by this tool.
224
+ # @return [Array<String>]
225
+ #
226
+ def used_switches
227
+ @switch_definitions.reduce([]) { |used, sdef| used + sdef.switches }.uniq
228
+ end
229
+
230
+ ##
231
+ # Sets the path to the file that defines this tool.
232
+ # A tool may be defined from at most one path. If a different path is
233
+ # already set, raises {Toys::ToolDefinitionError}
234
+ #
235
+ # @param [String] path The path to the file defining this tool
236
+ #
237
+ def definition_path=(path)
238
+ if @definition_path && @definition_path != path
239
+ raise ToolDefinitionError,
240
+ "Cannot redefine tool #{display_name.inspect} in #{path}" \
241
+ " (already defined in #{@definition_path})"
242
+ end
243
+ @definition_path = path
244
+ end
245
+
246
+ ##
247
+ # Set the short description.
248
+ #
249
+ # @param [String] str The short description
250
+ #
251
+ def desc=(str)
252
+ check_definition_state
253
+ @desc = str
254
+ end
255
+
256
+ ##
257
+ # Set the long description.
258
+ #
259
+ # @param [String] str The long description
260
+ #
261
+ def long_desc=(str)
262
+ check_definition_state
263
+ @long_desc = str
264
+ end
265
+
266
+ ##
267
+ # Define a helper method that will be available during execution.
268
+ # Pass the name of the method in the argument, and provide a block with
269
+ # the method body. Note the method name may not start with an underscore.
270
+ #
271
+ # @param [String] name The method name
272
+ #
273
+ def add_helper(name, &block)
274
+ check_definition_state
275
+ name_str = name.to_s
276
+ unless name_str =~ /^[a-z]\w+$/
277
+ raise ToolDefinitionError, "Illegal helper name: #{name_str.inspect}"
278
+ end
279
+ @helpers[name.to_sym] = block
280
+ self
281
+ end
282
+
283
+ ##
284
+ # Mix in the given module during execution. You may provide the module
285
+ # itself, or the name of a well-known module under {Toys::Helpers}.
286
+ #
287
+ # @param [Module,String] name The module or module name.
288
+ #
289
+ def use_module(name)
290
+ check_definition_state
291
+ case name
292
+ when ::Module
293
+ @modules << name
294
+ when ::Symbol
295
+ mod = Helpers.lookup(name.to_s)
296
+ if mod.nil?
297
+ raise ToolDefinitionError, "Module not found: #{name.inspect}"
298
+ end
299
+ @modules << mod
300
+ else
301
+ raise ToolDefinitionError, "Illegal helper module name: #{name.inspect}"
302
+ end
303
+ self
304
+ end
305
+
306
+ ##
307
+ # Add a switch to the current tool. Each switch must specify a key which
308
+ # the executor may use to obtain the switch value from the context.
309
+ # You may then provide the switches themselves in `OptionParser` form.
310
+ #
311
+ # @param [Symbol] key The key to use to retrieve the value from the
312
+ # execution context.
313
+ # @param [String...] switches The switches in OptionParser format.
314
+ # @param [Object,nil] accept An OptionParser acceptor. Optional.
315
+ # @param [Object] default The default value. This is the value that will
316
+ # be set in the context if this switch is not provided on the command
317
+ # line. Defaults to `nil`.
318
+ # @param [String,nil] doc The documentation for the switch, which appears
319
+ # in the usage documentation. Defaults to `nil` for no documentation.
320
+ # @param [Boolean] only_unique If true, any switches that are already
321
+ # defined in this tool are removed from this switch. For example, if
322
+ # an earlier switch uses `-a`, and this switch wants to use both
323
+ # `-a` and `-b`, then only `-b` will be assigned to this switch.
324
+ # Defaults to false.
325
+ # @param [Proc,nil] handler An optional handler for setting/updating the
326
+ # value. If given, it should take two arguments, the new given value
327
+ # and the previous value, and it should return the new value that
328
+ # should be set. The default handler simply replaces the previous
329
+ # value. i.e. the default is effectively `-> (val, _prev) { val }`.
330
+ #
331
+ def add_switch(key, *switches,
332
+ accept: nil, default: nil, doc: nil, only_unique: false, handler: nil)
333
+ check_definition_state
334
+ switches << "--#{Tool.canonical_switch(key)}=VALUE" if switches.empty?
335
+ bad_switch = switches.find { |s| Tool.extract_switch(s).empty? }
336
+ if bad_switch
337
+ raise ToolDefinitionError, "Illegal switch: #{bad_switch.inspect}"
338
+ end
339
+ switch_info = SwitchDefinition.new(key, switches + Array(accept) + Array(doc), handler)
340
+ if only_unique
341
+ switch_info.remove_switches(used_switches)
342
+ end
343
+ if switch_info.active?
344
+ @default_data[key] = default
345
+ @switch_definitions << switch_info
346
+ end
347
+ self
348
+ end
349
+
350
+ ##
351
+ # Add a required positional argument to the current tool. You must specify
352
+ # a key which the executor may use to obtain the argument value from the
353
+ # context.
354
+ #
355
+ # @param [Symbol] key The key to use to retrieve the value from the
356
+ # execution context.
357
+ # @param [Object,nil] accept An OptionParser acceptor. Optional.
358
+ # @param [String,nil] doc The documentation for the switch, which appears
359
+ # in the usage documentation. Defaults to `nil` for no documentation.
360
+ #
361
+ def add_required_arg(key, accept: nil, doc: nil)
362
+ check_definition_state
363
+ @default_data[key] = nil
364
+ @required_arg_definitions << ArgDefinition.new(key, accept, Array(doc))
365
+ self
366
+ end
367
+
368
+ ##
369
+ # Add an optional positional argument to the current tool. You must specify
370
+ # a key which the executor may use to obtain the argument value from the
371
+ # context. If an optional argument is not given on the command line, the
372
+ # value is set to the given default.
373
+ #
374
+ # @param [Symbol] key The key to use to retrieve the value from the
375
+ # execution context.
376
+ # @param [Object,nil] accept An OptionParser acceptor. Optional.
377
+ # @param [Object] default The default value. This is the value that will
378
+ # be set in the context if this argument is not provided on the command
379
+ # line. Defaults to `nil`.
380
+ # @param [String,nil] doc The documentation for the argument, which appears
381
+ # in the usage documentation. Defaults to `nil` for no documentation.
382
+ #
383
+ def add_optional_arg(key, accept: nil, default: nil, doc: nil)
384
+ check_definition_state
385
+ @default_data[key] = default
386
+ @optional_arg_definitions << ArgDefinition.new(key, accept, Array(doc))
387
+ self
388
+ end
389
+
390
+ ##
391
+ # Specify what should be done with unmatched positional arguments. You must
392
+ # specify a key which the executor may use to obtain the remaining args
393
+ # from the context.
394
+ #
395
+ # @param [Symbol] key The key to use to retrieve the value from the
396
+ # execution context.
397
+ # @param [Object,nil] accept An OptionParser acceptor. Optional.
398
+ # @param [Object] default The default value. This is the value that will
399
+ # be set in the context if no unmatched arguments are provided on the
400
+ # command line. Defaults to the empty array `[]`.
401
+ # @param [String,nil] doc The documentation for the remaining arguments,
402
+ # which appears in the usage documentation. Defaults to `nil` for no
403
+ # documentation.
404
+ #
405
+ def set_remaining_args(key, accept: nil, default: [], doc: nil)
406
+ check_definition_state
407
+ @default_data[key] = default
408
+ @remaining_args_definition = ArgDefinition.new(key, accept, Array(doc))
409
+ self
410
+ end
411
+
412
+ ##
413
+ # Set the executor for this tool. This is a proc that will be called,
414
+ # with `self` set to a {Toys::Context}.
415
+ #
416
+ # @param [Proc] executor The executor for this tool.
417
+ #
418
+ def executor=(executor)
419
+ check_definition_state
420
+ @executor = executor
421
+ end
422
+
423
+ ##
424
+ # Execute this tool in the given context.
425
+ #
426
+ # @param [Toys::CLI] cli The CLI execution context
427
+ # @param [Array<String>] args The arguments to pass to the tool. Should
428
+ # not include the tool name.
429
+ # @param [Integer] verbosity The starting verbosity. Defaults to 0.
430
+ #
431
+ # @return [Integer] The result code.
432
+ #
433
+ def execute(cli, args, verbosity: 0)
434
+ finish_definition unless @definition_finished
435
+ Execution.new(self).execute(cli, args, verbosity: verbosity)
436
+ end
437
+
438
+ ##
439
+ # Complete definition and run middleware configs
440
+ #
441
+ # @private
442
+ #
443
+ def finish_definition
444
+ unless @definition_finished
445
+ config_proc = proc {}
446
+ middleware_stack.reverse.each do |middleware|
447
+ config_proc = make_config_proc(middleware, config_proc)
448
+ end
449
+ config_proc.call
450
+ @definition_finished = true
451
+ end
452
+ self
453
+ end
454
+
455
+ private
456
+
457
+ def make_config_proc(middleware, next_config)
458
+ proc { middleware.config(self, &next_config) }
459
+ end
460
+
461
+ def check_definition_state
462
+ if @definition_finished
463
+ raise ToolDefinitionError,
464
+ "Defintion of tool #{display_name.inspect} is already finished"
465
+ end
466
+ end
467
+
468
+ class << self
469
+ ## @private
470
+ def canonical_switch(name)
471
+ name.to_s.downcase.tr("_", "-").gsub(/[^a-z0-9-]/, "")
472
+ end
473
+
474
+ ## @private
475
+ def extract_switch(str)
476
+ if !str.is_a?(String)
477
+ []
478
+ elsif str =~ /^(-[\?\w])(\s?\w+)?$/
479
+ [$1]
480
+ elsif str =~ /^--\[no-\](\w[\?\w-]*)$/
481
+ ["--#{$1}", "--no-#{$1}"]
482
+ elsif str =~ /^(--\w[\?\w-]*)([=\s]\w+)?$/
483
+ [$1]
484
+ else
485
+ []
486
+ end
487
+ end
488
+ end
489
+
490
+ ##
491
+ # Representation of a formal switch.
492
+ #
493
+ class SwitchDefinition
494
+ ##
495
+ # Create a SwitchDefinition
496
+ #
497
+ # @param [Symbol] key This switch will set the given context key.
498
+ # @param [Array<String>] optparse_info The switch definition in
499
+ # OptionParser format
500
+ # @param [Proc,nil] handler An optional handler for setting/updating the
501
+ # value. If given, it should take two arguments, the new given value
502
+ # and the previous value, and it should return the new value that
503
+ # should be set. If `nil`, uses a default handler that just replaces
504
+ # the previous value. i.e. the default is effectively
505
+ # `-> (val, _prev) { val }`.
506
+ #
507
+ def initialize(key, optparse_info, handler = nil)
508
+ @key = key
509
+ @optparse_info = optparse_info
510
+ @handler = handler || ->(val, _prev) { val }
511
+ @switches = nil
512
+ end
513
+
514
+ ##
515
+ # Returns the key.
516
+ # @return [Symbol]
517
+ #
518
+ attr_reader :key
519
+
520
+ ##
521
+ # Returns the OptionParser definition.
522
+ # @return [Array<String>]
523
+ #
524
+ attr_reader :optparse_info
525
+
526
+ ##
527
+ # Returns the handler.
528
+ # @return [Proc]
529
+ #
530
+ attr_reader :handler
531
+
532
+ ##
533
+ # Returns the list of switches used.
534
+ # @return [Array<String>]
535
+ #
536
+ def switches
537
+ @switches ||= optparse_info.map { |s| Tool.extract_switch(s) }.flatten
538
+ end
539
+
540
+ ##
541
+ # Returns true if this switch is active. That is, it has a nonempty
542
+ # switches list.
543
+ # @return [Boolean]
544
+ #
545
+ def active?
546
+ !switches.empty?
547
+ end
548
+
549
+ ##
550
+ # Removes the given switches.
551
+ # @param [Array<String>] switches
552
+ #
553
+ def remove_switches(switches)
554
+ @optparse_info.select! do |s|
555
+ Tool.extract_switch(s).all? { |ss| !switches.include?(ss) }
556
+ end
557
+ @switches = nil
558
+ self
559
+ end
560
+ end
561
+
562
+ ##
563
+ # Representation of a formal positional argument
564
+ #
565
+ class ArgDefinition
566
+ ##
567
+ # Create an ArgDefinition
568
+ #
569
+ # @param [Symbol] key This argument will set the given context key.
570
+ # @param [Object] accept An OptionParser acceptor
571
+ # @param [Array<String>] doc An array of documentation strings
572
+ #
573
+ def initialize(key, accept, doc)
574
+ @key = key
575
+ @accept = accept
576
+ @doc = doc
577
+ end
578
+
579
+ ##
580
+ # Returns the key.
581
+ # @return [Symbol]
582
+ #
583
+ attr_reader :key
584
+
585
+ ##
586
+ # Returns the acceptor.
587
+ # @return [Object]
588
+ #
589
+ attr_reader :accept
590
+
591
+ ##
592
+ # Returns the documentation strings.
593
+ # @return [Array<String>]
594
+ #
595
+ attr_reader :doc
596
+
597
+ ##
598
+ # Return a canonical name for this arg. Used in usage documentation.
599
+ #
600
+ # @return [String]
601
+ #
602
+ def canonical_name
603
+ Tool.canonical_switch(key)
604
+ end
605
+
606
+ ##
607
+ # Process the given value through the acceptor.
608
+ #
609
+ # @private
610
+ #
611
+ def process_value(val)
612
+ return val unless accept
613
+ n = canonical_name
614
+ result = val
615
+ optparse = ::OptionParser.new
616
+ optparse.on("--#{n}=VALUE", accept) { |v| result = v }
617
+ optparse.parse(["--#{n}", val])
618
+ result
619
+ end
620
+ end
621
+
622
+ ##
623
+ # An internal class that manages execution of a tool
624
+ # @private
625
+ #
626
+ class Execution
627
+ def initialize(tool)
628
+ @tool = tool
629
+ @data = @tool.default_data.dup
630
+ @data[Context::TOOL] = tool
631
+ @data[Context::TOOL_NAME] = tool.full_name
632
+ end
633
+
634
+ def execute(cli, args, verbosity: 0)
635
+ parse_args(args, verbosity)
636
+ context = create_child_context(cli)
637
+
638
+ original_level = context.logger.level
639
+ context.logger.level = cli.base_level - @data[Context::VERBOSITY]
640
+ begin
641
+ perform_execution(context)
642
+ ensure
643
+ context.logger.level = original_level
644
+ end
645
+ end
646
+
647
+ private
648
+
649
+ def parse_args(args, base_verbosity)
650
+ optparse = create_option_parser
651
+ @data[Context::VERBOSITY] = base_verbosity
652
+ @data[Context::ARGS] = args
653
+ @data[Context::USAGE_ERROR] = nil
654
+ remaining = optparse.parse(args)
655
+ remaining = parse_required_args(remaining, args)
656
+ remaining = parse_optional_args(remaining)
657
+ parse_remaining_args(remaining, args)
658
+ rescue ::OptionParser::ParseError => e
659
+ @data[Context::USAGE_ERROR] = e.message
660
+ end
661
+
662
+ def create_option_parser
663
+ optparse = ::OptionParser.new
664
+ # The following clears out the Officious (hidden default switches).
665
+ optparse.remove
666
+ optparse.remove
667
+ optparse.new
668
+ optparse.new
669
+ @tool.switch_definitions.each do |switch|
670
+ optparse.on(*switch.optparse_info) do |val|
671
+ @data[switch.key] = switch.handler.call(val, @data[switch.key])
672
+ end
673
+ end
674
+ optparse
675
+ end
676
+
677
+ def parse_required_args(remaining, args)
678
+ @tool.required_arg_definitions.each do |arg_info|
679
+ if remaining.empty?
680
+ reason = "No value given for required argument named <#{arg_info.canonical_name}>"
681
+ raise create_parse_error(args, reason)
682
+ end
683
+ @data[arg_info.key] = arg_info.process_value(remaining.shift)
684
+ end
685
+ remaining
686
+ end
687
+
688
+ def parse_optional_args(remaining)
689
+ @tool.optional_arg_definitions.each do |arg_info|
690
+ break if remaining.empty?
691
+ @data[arg_info.key] = arg_info.process_value(remaining.shift)
692
+ end
693
+ remaining
694
+ end
695
+
696
+ def parse_remaining_args(remaining, args)
697
+ return if remaining.empty?
698
+ unless @tool.remaining_args_definition
699
+ if @tool.includes_executor?
700
+ raise create_parse_error(remaining, "Extra arguments provided")
701
+ else
702
+ raise create_parse_error(@tool.full_name + args, "Tool not found")
703
+ end
704
+ end
705
+ @data[@tool.remaining_args_definition.key] =
706
+ remaining.map { |arg| @tool.remaining_args_definition.process_value(arg) }
707
+ end
708
+
709
+ def create_parse_error(path, reason)
710
+ OptionParser::ParseError.new(*path).tap do |e|
711
+ e.reason = reason
712
+ end
713
+ end
714
+
715
+ def create_child_context(cli)
716
+ context = Context.new(cli, @data)
717
+ @tool.modules.each do |mod|
718
+ context.extend(mod)
719
+ end
720
+ @tool.helpers.each do |name, block|
721
+ context.define_singleton_method(name, &block)
722
+ end
723
+ context
724
+ end
725
+
726
+ def perform_execution(context)
727
+ executor = proc do
728
+ if @tool.includes_executor?
729
+ context.instance_eval(&@tool.executor)
730
+ else
731
+ context.logger.fatal("No implementation for #{@tool.display_name.inspect}")
732
+ context.exit(-1)
733
+ end
734
+ end
735
+ @tool.middleware_stack.reverse.each do |middleware|
736
+ executor = make_executor(middleware, context, executor)
737
+ end
738
+ catch(:result) do
739
+ executor.call
740
+ 0
741
+ end
742
+ end
743
+
744
+ def make_executor(middleware, context, next_executor)
745
+ proc { middleware.execute(context, &next_executor) }
746
+ end
747
+ end
748
+ end
749
+ end