toys-core 0.7.0 → 0.8.0

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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -0
  3. data/LICENSE.md +16 -24
  4. data/README.md +307 -59
  5. data/docs/guide.md +44 -4
  6. data/lib/toys-core.rb +58 -49
  7. data/lib/toys/acceptor.rb +672 -0
  8. data/lib/toys/alias.rb +106 -0
  9. data/lib/toys/arg_parser.rb +624 -0
  10. data/lib/toys/cli.rb +422 -181
  11. data/lib/toys/compat.rb +83 -0
  12. data/lib/toys/completion.rb +442 -0
  13. data/lib/toys/context.rb +354 -0
  14. data/lib/toys/core_version.rb +18 -26
  15. data/lib/toys/dsl/flag.rb +213 -56
  16. data/lib/toys/dsl/flag_group.rb +237 -51
  17. data/lib/toys/dsl/positional_arg.rb +210 -0
  18. data/lib/toys/dsl/tool.rb +968 -317
  19. data/lib/toys/errors.rb +46 -28
  20. data/lib/toys/flag.rb +821 -0
  21. data/lib/toys/flag_group.rb +282 -0
  22. data/lib/toys/input_file.rb +18 -26
  23. data/lib/toys/loader.rb +110 -100
  24. data/lib/toys/middleware.rb +24 -31
  25. data/lib/toys/mixin.rb +90 -59
  26. data/lib/toys/module_lookup.rb +125 -0
  27. data/lib/toys/positional_arg.rb +184 -0
  28. data/lib/toys/source_info.rb +192 -0
  29. data/lib/toys/standard_middleware/add_verbosity_flags.rb +38 -43
  30. data/lib/toys/standard_middleware/handle_usage_errors.rb +39 -40
  31. data/lib/toys/standard_middleware/set_default_descriptions.rb +111 -89
  32. data/lib/toys/standard_middleware/show_help.rb +130 -113
  33. data/lib/toys/standard_middleware/show_root_version.rb +29 -35
  34. data/lib/toys/standard_mixins/exec.rb +116 -78
  35. data/lib/toys/standard_mixins/fileutils.rb +16 -24
  36. data/lib/toys/standard_mixins/gems.rb +29 -30
  37. data/lib/toys/standard_mixins/highline.rb +34 -41
  38. data/lib/toys/standard_mixins/terminal.rb +72 -26
  39. data/lib/toys/template.rb +51 -35
  40. data/lib/toys/tool.rb +1161 -206
  41. data/lib/toys/utils/completion_engine.rb +171 -0
  42. data/lib/toys/utils/exec.rb +279 -182
  43. data/lib/toys/utils/gems.rb +58 -49
  44. data/lib/toys/utils/help_text.rb +117 -111
  45. data/lib/toys/utils/terminal.rb +69 -62
  46. data/lib/toys/wrappable_string.rb +162 -0
  47. metadata +24 -22
  48. data/lib/toys/definition/acceptor.rb +0 -191
  49. data/lib/toys/definition/alias.rb +0 -112
  50. data/lib/toys/definition/arg.rb +0 -140
  51. data/lib/toys/definition/flag.rb +0 -370
  52. data/lib/toys/definition/flag_group.rb +0 -205
  53. data/lib/toys/definition/source_info.rb +0 -190
  54. data/lib/toys/definition/tool.rb +0 -842
  55. data/lib/toys/dsl/arg.rb +0 -132
  56. data/lib/toys/runner.rb +0 -188
  57. data/lib/toys/standard_middleware.rb +0 -47
  58. data/lib/toys/utils/module_lookup.rb +0 -135
  59. data/lib/toys/utils/wrappable_string.rb +0 -165
@@ -1,167 +1,300 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright 2018 Daniel Azuma
3
+ # Copyright 2019 Daniel Azuma
4
4
  #
5
- # All rights reserved.
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
6
11
  #
7
- # Redistribution and use in source and binary forms, with or without
8
- # modification, are permitted provided that the following conditions are met:
12
+ # The above copyright notice and this permission notice shall be included in
13
+ # all copies or substantial portions of the Software.
9
14
  #
10
- # * Redistributions of source code must retain the above copyright notice,
11
- # this list of conditions and the following disclaimer.
12
- # * Redistributions in binary form must reproduce the above copyright notice,
13
- # this list of conditions and the following disclaimer in the documentation
14
- # and/or other materials provided with the distribution.
15
- # * Neither the name of the copyright holder, nor the names of any other
16
- # contributors to this software, may be used to endorse or promote products
17
- # derived from this software without specific prior written permission.
18
- #
19
- # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
- # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
- # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
22
- # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
23
- # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
24
- # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
25
- # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26
- # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
27
- # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
28
- # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29
- # POSSIBILITY OF SUCH DAMAGE.
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21
+ # IN THE SOFTWARE.
30
22
  ;
31
23
 
32
24
  require "logger"
25
+ require "toys/completion"
33
26
 
34
27
  module Toys
35
28
  ##
36
29
  # A Toys-based CLI.
37
30
  #
38
- # Use this class to implement a CLI using Toys.
31
+ # This is the entry point for command line execution. It includes the set of
32
+ # tool definitions (and/or information on how to load them from the file
33
+ # system), configuration parameters such as logging and error handling, and a
34
+ # method to call to invoke a command.
35
+ #
36
+ # This is the class to instantiate to create a Toys-based command line
37
+ # executable. For example:
38
+ #
39
+ # #!/usr/bin/env ruby
40
+ # require "toys-core"
41
+ # cli = Toys::CLI.new
42
+ # cli.add_config_block do
43
+ # def run
44
+ # puts "Hello, world!"
45
+ # end
46
+ # end
47
+ # exit(cli.run(*ARGV))
48
+ #
49
+ # The currently running CLI is also available at runtime, and can be used by
50
+ # tools that want to invoke other tools. For example:
51
+ #
52
+ # # My .toys.rb
53
+ # tool "foo" do
54
+ # def run
55
+ # puts "in foo"
56
+ # end
57
+ # end
58
+ # tool "bar" do
59
+ # def run
60
+ # puts "in bar"
61
+ # cli.run "foo"
62
+ # end
63
+ # end
39
64
  #
40
65
  class CLI
41
66
  ##
42
67
  # Create a CLI.
43
68
  #
44
- # @param [String,nil] binary_name The binary name displayed in help text.
45
- # Optional. Defaults to the ruby program name.
46
- # @param [String,nil] config_dir_name A directory with this name that
47
- # appears in the loader path, is treated as a configuration directory
48
- # whose contents are loaded into the toys configuration. Optional.
49
- # If not provided, toplevel configuration directories are disabled.
50
- # The default toys CLI sets this to `".toys"`.
51
- # @param [String,nil] config_file_name A file with this name that appears
52
- # in the loader path, is treated as a toplevel configuration file
53
- # whose contents are loaded into the toys configuration. Optional.
54
- # If not provided, toplevel configuration files are disabled.
55
- # The default toys CLI sets this to `".toys.rb"`.
56
- # @param [String,nil] index_file_name A file with this name that appears
57
- # in any configuration directory (not just a toplevel directory) is
58
- # loaded first as a standalone configuration file. If not provided,
59
- # standalone configuration files are disabled.
60
- # The default toys CLI sets this to `".toys.rb"`.
61
- # @param [String,nil] preload_file_name A file with this name that appears
62
- # in any configuration directory is preloaded before any tools in that
63
- # configuration directory are defined.
64
- # The default toys CLI sets this to `".preload.rb"`.
65
- # @param [String,nil] preload_directory_name A directory with this name
66
- # that appears in any configuration directory is searched for Ruby
67
- # files, which are preloaded before any tools in that configuration
68
- # directory are defined.
69
- # The default toys CLI sets this to `".preload"`.
70
- # @param [String,nil] data_directory_name A directory with this name that
71
- # appears in any configuration directory is added to the data directory
72
- # search path for any tool file in that directory.
73
- # The default toys CLI sets this to `".data"`.
74
- # @param [Array] middleware_stack An array of middleware that will be used
75
- # by default for all tools loaded by this CLI. If not provided, uses
76
- # {Toys::CLI.default_middleware_stack}.
77
- # @param [String] extra_delimiters A string containing characters that can
78
- # function as delimiters in a tool name. Defaults to empty. Allowed
79
- # characters are period, colon, and slash.
80
- # @param [Toys::Utils::ModuleLookup] mixin_lookup A lookup for well-known
81
- # mixin modules. If not provided, uses
82
- # {Toys::CLI.default_mixin_lookup}.
83
- # @param [Toys::Utils::ModuleLookup] middleware_lookup A lookup for
84
- # well-known middleware classes. If not provided, uses
85
- # {Toys::CLI.default_middleware_lookup}.
86
- # @param [Toys::Utils::ModuleLookup] template_lookup A lookup for
87
- # well-known template classes. If not provided, uses
88
- # {Toys::CLI.default_template_lookup}.
89
- # @param [Logger,nil] logger The logger to use. If not provided, a default
90
- # logger that writes to `STDERR` is used.
91
- # @param [Integer,nil] base_level The logger level that should correspond
92
- # to zero verbosity. If not provided, will default to the current level
93
- # of the logger.
94
- # @param [Proc,nil] error_handler A proc that is called when an error is
69
+ # Most configuration parameters (besides tool definitions and tool lookup
70
+ # paths) are set as options passed to the constructor. These options fall
71
+ # roughly into four categories:
72
+ #
73
+ # * Options affecting output behavior:
74
+ # * `logger`: The logger
75
+ # * `base_level`: The default log level
76
+ # * `error_handler`: Callback for handling exceptions
77
+ # * `executable_name`: The name of the executable
78
+ # * Options affecting tool specification
79
+ # * `extra_delimibers`: Tool name delimiters besides space
80
+ # * `completion`: Tab completion handler
81
+ # * Options affecting tool definition
82
+ # * `middleware_stack`: The middleware applied to all tools
83
+ # * `mixin_lookup`: Where to find well-known mixins
84
+ # * `middleware_lookup`: Where to find well-known middleware
85
+ # * `template_lookup`: Where to find well-known templates
86
+ # * Options affecting tool files and directories
87
+ # * `config_dir_name`: Directory name containing tool files
88
+ # * `config_file_name`: File name for tools
89
+ # * `index_file_name`: Name of index files in tool directories
90
+ # * `preload_file_name`: Name of preload files in tool directories
91
+ # * `preload_dir_name`: Name of preload directories in tool directories
92
+ # * `data_dir_name`: Name of data directories in tool directories
93
+ #
94
+ # @param logger [Logger] The logger to use.
95
+ # Optional. If not provided, will use a default logger that writes
96
+ # formatted output to `STDERR`, as defined by
97
+ # {Toys::CLI.default_logger}.
98
+ # @param base_level [Integer] The logger level that should correspond
99
+ # to zero verbosity.
100
+ # Optional. If not provided, defaults to the current level of the
101
+ # logger (which is often `Logger::WARN`).
102
+ # @param error_handler [Proc,nil] A proc that is called when an error is
95
103
  # caught. The proc should take a {Toys::ContextualError} argument and
96
104
  # report the error. It should return an exit code (normally nonzero).
97
- # Default is a {Toys::CLI::DefaultErrorHandler} writing to the logger.
105
+ # Optional. If not provided, defaults to an instance of
106
+ # {Toys::CLI::DefaultErrorHandler}, which displays an error message to
107
+ # `STDERR`.
108
+ # @param executable_name [String] The executable name displayed in help
109
+ # text. Optional. Defaults to the ruby program name.
110
+ #
111
+ # @param extra_delimiters [String] A string containing characters that can
112
+ # function as delimiters in a tool name. Defaults to empty. Allowed
113
+ # characters are period, colon, and slash.
114
+ # @param completion [Toys::Completion::Base] A specifier for shell tab
115
+ # completion for the CLI as a whole.
116
+ # Optional. If not provided, defaults to an instance of
117
+ # {Toys::CLI::DefaultCompletion}, which delegates completion to the
118
+ # relevant tool.
119
+ #
120
+ # @param middleware_stack [Array] An array of middleware that will be used
121
+ # by default for all tools loaded by this CLI.
122
+ # Optional. If not provided, uses a default set of middleware defined
123
+ # in {Toys::CLI.default_middleware_stack}. To include no middleware,
124
+ # pass the empty array explicitly.
125
+ # @param mixin_lookup [Toys::ModuleLookup] A lookup for well-known mixin
126
+ # modules (i.e. with symbol names).
127
+ # Optional. If not provided, defaults to the set of standard mixins
128
+ # provided by toys-core, as defined by
129
+ # {Toys::CLI.default_mixin_lookup}. If you explicitly want no standard
130
+ # mixins, pass an empty instance of {Toys::ModuleLookup}.
131
+ # @param middleware_lookup [Toys::ModuleLookup] A lookup for well-known
132
+ # middleware classes.
133
+ # Optional. If not provided, defaults to the set of standard middleware
134
+ # classes provided by toys-core, as defined by
135
+ # {Toys::CLI.default_middleware_lookup}. If you explicitly want no
136
+ # standard middleware, pass an empty instance of
137
+ # {Toys::ModuleLookup}.
138
+ # @param template_lookup [Toys::ModuleLookup] A lookup for well-known
139
+ # template classes.
140
+ # Optional. If not provided, defaults to the set of standard template
141
+ # classes provided by toys core, as defined by
142
+ # {Toys::CLI.default_template_lookup}. If you explicitly want no
143
+ # standard tenokates, pass an empty instance of {Toys::ModuleLookup}.
144
+ #
145
+ # @param config_dir_name [String] A directory with this name that appears
146
+ # in the loader path, is treated as a configuration directory whose
147
+ # contents are loaded into the toys configuration.
148
+ # Optional. If not provided, toplevel configuration directories are
149
+ # disabled.
150
+ # Note: the standard toys executable sets this to `".toys"`.
151
+ # @param config_file_name [String] A file with this name that appears in
152
+ # the loader path, is treated as a toplevel configuration file whose
153
+ # contents are loaded into the toys configuration. This does not
154
+ # include "index" configuration files located within a configuration
155
+ # directory.
156
+ # Optional. If not provided, toplevel configuration files are disabled.
157
+ # Note: the standard toys executable sets this to `".toys.rb"`.
158
+ # @param index_file_name [String] A file with this name that appears in any
159
+ # configuration directory is loaded first as a standalone configuration
160
+ # file. This does not include "toplevel" configuration files outside
161
+ # configuration directories.
162
+ # Optional. If not provided, index configuration files are disabled.
163
+ # Note: the standard toys executable sets this to `".toys.rb"`.
164
+ # @param preload_file_name [String] A file with this name that appears
165
+ # in any configuration directory is preloaded using `require` before
166
+ # any tools in that configuration directory are defined. A preload file
167
+ # includes normal Ruby code, rather than Toys DSL definitions. The
168
+ # preload file is loaded before any files in a preload directory.
169
+ # Optional. If not provided, preload files are disabled.
170
+ # Note: the standard toys executable sets this to `".preload.rb"`.
171
+ # @param preload_dir_name [String] A directory with this name that appears
172
+ # in any configuration directory is searched for Ruby files, which are
173
+ # preloaded using `require` before any tools in that configuration
174
+ # directory are defined. Files in a preload directory include normal
175
+ # Ruby code, rather than Toys DSL definitions. Files in a preload
176
+ # directory are loaded after any standalone preload file.
177
+ # Optional. If not provided, preload directories are disabled.
178
+ # Note: the standard toys executable sets this to `".preload"`.
179
+ # @param data_dir_name [String] A directory with this name that appears in
180
+ # any configuration directory is added to the data directory search
181
+ # path for any tool file in that directory.
182
+ # Optional. If not provided, data directories are disabled.
183
+ # Note: the standard toys executable sets this to `".data"`.
98
184
  #
99
185
  def initialize(
100
- binary_name: nil, middleware_stack: nil, extra_delimiters: "",
186
+ executable_name: nil, middleware_stack: nil, extra_delimiters: "",
101
187
  config_dir_name: nil, config_file_name: nil, index_file_name: nil,
102
- preload_file_name: nil, preload_directory_name: nil, data_directory_name: nil,
188
+ preload_file_name: nil, preload_dir_name: nil, data_dir_name: nil,
103
189
  mixin_lookup: nil, middleware_lookup: nil, template_lookup: nil,
104
- logger: nil, base_level: nil, error_handler: nil
190
+ logger: nil, base_level: nil, error_handler: nil, completion: nil
105
191
  )
106
- @logger = logger || self.class.default_logger
192
+ @executable_name = executable_name || ::File.basename($PROGRAM_NAME)
193
+ @middleware_stack = middleware_stack || CLI.default_middleware_stack
194
+ @mixin_lookup = mixin_lookup || CLI.default_mixin_lookup
195
+ @middleware_lookup = middleware_lookup || CLI.default_middleware_lookup
196
+ @template_lookup = template_lookup || CLI.default_template_lookup
197
+ @error_handler = error_handler || DefaultErrorHandler.new
198
+ @completion = completion || DefaultCompletion.new
199
+ @logger = logger || CLI.default_logger
107
200
  @base_level = base_level || @logger.level
108
- @middleware_stack = middleware_stack || self.class.default_middleware_stack
109
201
  @extra_delimiters = extra_delimiters
110
- @binary_name = binary_name || ::File.basename($PROGRAM_NAME)
111
202
  @config_dir_name = config_dir_name
112
203
  @config_file_name = config_file_name
113
204
  @index_file_name = index_file_name
114
205
  @preload_file_name = preload_file_name
115
- @preload_directory_name = preload_directory_name
116
- @data_directory_name = data_directory_name
117
- @mixin_lookup = mixin_lookup || self.class.default_mixin_lookup
118
- @middleware_lookup = middleware_lookup || self.class.default_middleware_lookup
119
- @template_lookup = template_lookup || self.class.default_template_lookup
206
+ @preload_dir_name = preload_dir_name
207
+ @data_dir_name = data_dir_name
120
208
  @loader = Loader.new(
121
- index_file_name: index_file_name, extra_delimiters: @extra_delimiters,
122
- preload_directory_name: @preload_directory_name, preload_file_name: @preload_file_name,
123
- data_directory_name: @data_directory_name,
209
+ index_file_name: @index_file_name, extra_delimiters: @extra_delimiters,
210
+ preload_dir_name: @preload_dir_name, preload_file_name: @preload_file_name,
211
+ data_dir_name: @data_dir_name,
124
212
  mixin_lookup: @mixin_lookup, template_lookup: @template_lookup,
125
213
  middleware_lookup: @middleware_lookup, middleware_stack: @middleware_stack
126
214
  )
127
- @error_handler = error_handler || DefaultErrorHandler.new
128
215
  end
129
216
 
130
217
  ##
131
- # Return the current loader for this CLI
218
+ # Make a clone with the same settings but no paths in the loader.
219
+ # This is sometimes useful for running sub-tools that have to be loaded
220
+ # from a different configuration.
221
+ #
222
+ # @param _opts [Hash] Unused options that can be used by subclasses.
223
+ # @return [Toys::CLI]
224
+ # @yieldparam cli [Toys::CLI] If you pass a block, the new CLI is yielded
225
+ # to it so you can add paths and make other modifications.
226
+ #
227
+ def child(_opts = {})
228
+ cli = CLI.new(executable_name: @executable_name,
229
+ config_dir_name: @config_dir_name,
230
+ config_file_name: @config_file_name,
231
+ index_file_name: @index_file_name,
232
+ preload_dir_name: @preload_dir_name,
233
+ preload_file_name: @preload_file_name,
234
+ data_dir_name: @data_dir_name,
235
+ middleware_stack: @middleware_stack,
236
+ extra_delimiters: @extra_delimiters,
237
+ mixin_lookup: @mixin_lookup,
238
+ middleware_lookup: @middleware_lookup,
239
+ template_lookup: @template_lookup,
240
+ logger: @logger,
241
+ base_level: @base_level,
242
+ error_handler: @error_handler,
243
+ completion: @completion)
244
+ yield cli if block_given?
245
+ cli
246
+ end
247
+
248
+ ##
249
+ # The current loader for this CLI.
132
250
  # @return [Toys::Loader]
133
251
  #
134
252
  attr_reader :loader
135
253
 
136
254
  ##
137
- # Return the effective binary name used for usage text in this CLI
255
+ # The effective executable name used for usage text in this CLI.
256
+ # @return [String]
257
+ #
258
+ attr_reader :executable_name
259
+
260
+ ##
261
+ # The string of tool name delimiter characters (besides space).
138
262
  # @return [String]
139
263
  #
140
- attr_reader :binary_name
264
+ attr_reader :extra_delimiters
141
265
 
142
266
  ##
143
- # Return the logger used by this CLI
267
+ # The logger used by this CLI.
144
268
  # @return [Logger]
145
269
  #
146
270
  attr_reader :logger
147
271
 
148
272
  ##
149
- # Return the initial logger level in this CLI, used as the level for
150
- # verbosity 0.
273
+ # The initial logger level in this CLI, used as the level for verbosity 0.
151
274
  # @return [Integer]
152
275
  #
153
276
  attr_reader :base_level
154
277
 
155
278
  ##
156
- # Add a configuration file or directory to the loader.
279
+ # The overall completion strategy for this CLI.
280
+ # @return [Toys::Completion::Base,Proc]
281
+ #
282
+ attr_reader :completion
283
+
284
+ ##
285
+ # Add a specific configuration file or directory to the loader.
157
286
  #
158
- # If a CLI has a default tool set, it might use this to point to the
159
- # directory that defines those tools. For example, the default Toys CLI
160
- # uses this to load the builtin tools from the "builtins" directory.
287
+ # This is generally used to load a static or "built-in" set of tools,
288
+ # either for a standalone command line executable based on Toys, or to
289
+ # provide a "default" set of tools for a dynamic executable. For example,
290
+ # the main Toys executable uses this to load the builtin tools from its
291
+ # "builtins" directory.
161
292
  #
162
- # @param [String] path A path to add.
163
- # @param [Boolean] high_priority Add the config at the head of the priority
293
+ # @param path [String] A path to add. May reference a single Toys file or
294
+ # a Toys directory.
295
+ # @param high_priority [Boolean] Add the config at the head of the priority
164
296
  # list rather than the tail.
297
+ # @return [self]
165
298
  #
166
299
  def add_config_path(path, high_priority: false)
167
300
  @loader.add_path(path, high_priority: high_priority)
@@ -171,11 +304,17 @@ module Toys
171
304
  ##
172
305
  # Add a configuration block to the loader.
173
306
  #
174
- # @param [Boolean] high_priority Add the config at the head of the priority
307
+ # This is used to create tools "inline", and is useful for simple command
308
+ # line executables based on Toys.
309
+ #
310
+ # @param high_priority [Boolean] Add the config at the head of the priority
175
311
  # list rather than the tail.
176
- # @param [String] name The source name that will be shown in documentation
312
+ # @param name [String] The source name that will be shown in documentation
177
313
  # for tools defined in this block. If omitted, a default unique string
178
314
  # will be generated.
315
+ # @param block [Proc] The block of configuration, executed in the context
316
+ # of the tool DSL {Toys::DSL::Tool}.
317
+ # @return [self]
179
318
  #
180
319
  def add_config_block(high_priority: false, name: nil, &block)
181
320
  @loader.add_block(high_priority: high_priority, name: name, &block)
@@ -183,16 +322,16 @@ module Toys
183
322
  end
184
323
 
185
324
  ##
186
- # Searches the given directory for a well-known config directory and/or
187
- # config file. If found, these are added to the loader.
325
+ # Checks the given directory path. If it contains a config file and/or
326
+ # config directory, those are added to the loader.
188
327
  #
189
- # Typically, a CLI will use this to find toys configs in the current
190
- # working directory, the user's home directory, or some other well-known
191
- # general configuration-oriented directory such as "/etc".
328
+ # The main Toys executable uses this method to load tools from directories
329
+ # in the `TOYS_PATH`.
192
330
  #
193
- # @param [String] search_path A path to search for configs.
194
- # @param [Boolean] high_priority Add the configs at the head of the
331
+ # @param search_path [String] A path to search for configs.
332
+ # @param high_priority [Boolean] Add the configs at the head of the
195
333
  # priority list rather than the tail.
334
+ # @return [self]
196
335
  #
197
336
  def add_search_path(search_path, high_priority: false)
198
337
  paths = []
@@ -209,15 +348,21 @@ module Toys
209
348
  end
210
349
 
211
350
  ##
212
- # A convenience method that searches the current working directory, and all
213
- # ancestor directories, for configs to add to the loader.
351
+ # Walk up the directory hierarchy from the given start location, and add to
352
+ # the loader any config files and directories found.
214
353
  #
215
- # @param [String] start The first directory to add. Defaults to the current
354
+ # The main Toys executable uses this method to load tools from the current
355
+ # directory and its ancestors.
356
+ #
357
+ # @param start [String] The first directory to add. Defaults to the current
216
358
  # working directory.
217
- # @param [Array<String>] terminate Optional list of directories that should
218
- # terminate the search.
219
- # @param [Boolean] high_priority Add the configs at the head of the
359
+ # @param terminate [Array<String>] Optional list of directories that should
360
+ # terminate the search. If the walk up the directory tree encounters
361
+ # one of these directories, the search is halted without checking the
362
+ # terminating directory.
363
+ # @param high_priority [Boolean] Add the configs at the head of the
220
364
  # priority list rather than the tail.
365
+ # @return [self]
221
366
  #
222
367
  def add_search_path_hierarchy(start: nil, terminate: [], high_priority: false)
223
368
  path = start || ::Dir.pwd
@@ -238,55 +383,108 @@ module Toys
238
383
 
239
384
  ##
240
385
  # Run the CLI with the given command line arguments.
386
+ # Handles exceptions using the error handler.
241
387
  #
242
- # @param [String...] args Command line arguments specifying which tool to
388
+ # @param args [String...] Command line arguments specifying which tool to
243
389
  # run and what arguments to pass to it. You may pass either a single
244
390
  # array of strings, or a series of string arguments.
245
- # @param [Integer] verbosity Initial verbosity. Default is 0.
391
+ # @param verbosity [Integer] Initial verbosity. Default is 0.
246
392
  #
247
- # @return [Integer] The resulting status code
393
+ # @return [Integer] The resulting process status code (i.e. 0 for success).
248
394
  #
249
395
  def run(*args, verbosity: 0)
250
- tool_definition, remaining = ContextualError.capture("Error finding tool definition") do
396
+ tool, remaining = ContextualError.capture("Error finding tool definition") do
251
397
  @loader.lookup(args.flatten)
252
398
  end
253
399
  ContextualError.capture_path(
254
- "Error during tool execution!", tool_definition.source_info&.source_path,
255
- tool_name: tool_definition.full_name, tool_args: remaining
400
+ "Error during tool execution!", tool.source_info&.source_path,
401
+ tool_name: tool.full_name, tool_args: remaining
256
402
  ) do
257
- Runner.new(self, tool_definition).run(remaining, verbosity: verbosity)
403
+ run_tool(tool, remaining, verbosity: verbosity)
258
404
  end
259
- rescue ContextualError => e
260
- @error_handler.call(e)
405
+ rescue ContextualError, ::Interrupt => e
406
+ @error_handler.call(e).to_i
261
407
  end
262
408
 
409
+ private
410
+
263
411
  ##
264
- # Make a clone with the same settings but no paths in the loader.
265
- # This is sometimes useful for running sub-tools.
412
+ # Run the given tool with the given arguments.
413
+ # Does not handle exceptions.
266
414
  #
267
- # @param [Hash] _opts Unused options that can be used by subclasses.
268
- # @return [Toys::CLI]
269
- # @yieldparam cli [Toys::CLI] If you pass a block, the new CLI is yielded
270
- # to it so you can add paths and make other modifications.
415
+ # @param tool [Toys::Tool] The tool to run.
416
+ # @param args [Array<String>] Command line arguments passed to the tool.
417
+ # @param verbosity [Integer] Initial verbosity. Default is 0.
418
+ # @return [Integer] The resulting status code
271
419
  #
272
- def child(_opts = {})
273
- cli = CLI.new(binary_name: @binary_name,
274
- config_dir_name: @config_dir_name,
275
- config_file_name: @config_file_name,
276
- index_file_name: @index_file_name,
277
- preload_directory_name: @preload_directory_name,
278
- preload_file_name: @preload_file_name,
279
- data_directory_name: @data_directory_name,
280
- middleware_stack: @middleware_stack,
281
- extra_delimiters: @extra_delimiters,
282
- mixin_lookup: @mixin_lookup,
283
- middleware_lookup: @middleware_lookup,
284
- template_lookup: @template_lookup,
285
- logger: @logger,
286
- base_level: @base_level,
287
- error_handler: @error_handler)
288
- yield cli if block_given?
289
- cli
420
+ def run_tool(tool, args, verbosity: 0)
421
+ arg_parser = ArgParser.new(self, tool,
422
+ verbosity: verbosity,
423
+ require_exact_flag_match: tool.exact_flag_match_required?)
424
+ arg_parser.parse(args).finish
425
+ context = tool.tool_class.new(arg_parser.data)
426
+ tool.run_initializers(context)
427
+
428
+ cur_logger = logger
429
+ original_level = cur_logger.level
430
+ cur_logger.level = base_level - context[Context::Key::VERBOSITY]
431
+ begin
432
+ perform_execution(context, tool)
433
+ ensure
434
+ cur_logger.level = original_level
435
+ end
436
+ end
437
+
438
+ def perform_execution(context, tool)
439
+ executor = proc do
440
+ begin
441
+ if !context[Context::Key::USAGE_ERRORS].empty?
442
+ handle_usage_errors(context, tool)
443
+ elsif !tool.runnable?
444
+ raise NotRunnableError, "No implementation for tool #{tool.display_name.inspect}"
445
+ else
446
+ context.run
447
+ end
448
+ rescue ::Interrupt => e
449
+ raise e unless tool.handles_interrupts?
450
+ handle_interrupt(context, tool.interrupt_handler, e)
451
+ end
452
+ end
453
+ tool.middleware_stack.reverse_each do |middleware|
454
+ executor = make_executor(middleware, context, executor)
455
+ end
456
+ catch(:result) do
457
+ executor.call
458
+ 0
459
+ end
460
+ end
461
+
462
+ def handle_usage_errors(context, tool)
463
+ usage_errors = context[Context::Key::USAGE_ERRORS]
464
+ handler = tool.usage_error_handler
465
+ raise ArgParsingError, usage_errors if handler.nil?
466
+ handler = context.method(handler).to_proc if handler.is_a?(::Symbol)
467
+ if handler.arity.zero?
468
+ context.instance_exec(&handler)
469
+ else
470
+ context.instance_exec(usage_errors, &handler)
471
+ end
472
+ end
473
+
474
+ def handle_interrupt(context, handler, exception)
475
+ handler = context.method(handler).to_proc if handler.is_a?(::Symbol)
476
+ if handler.arity.zero?
477
+ context.instance_exec(&handler)
478
+ else
479
+ context.instance_exec(exception, &handler)
480
+ end
481
+ rescue ::Interrupt => e
482
+ raise e if e.equal?(exception)
483
+ handle_interrupt(context, handler, e)
484
+ end
485
+
486
+ def make_executor(middleware, context, next_executor)
487
+ proc { middleware.run(context, &next_executor) }
290
488
  end
291
489
 
292
490
  ##
@@ -297,25 +495,51 @@ module Toys
297
495
  ##
298
496
  # Create an error handler.
299
497
  #
300
- # @param [IO] output Where to write errors. Default is `$stderr`.
498
+ # @param output [IO,nil] Where to write errors. Default is `$stderr`.
301
499
  #
302
- def initialize(output = $stderr)
500
+ def initialize(output: $stderr)
501
+ require "toys/utils/terminal"
303
502
  @terminal = Utils::Terminal.new(output: output)
304
503
  end
305
504
 
306
505
  ##
307
- # The error handler routine. Prints out the error message and backtrace.
506
+ # The error handler routine. Prints out the error message and backtrace,
507
+ # and returns the correct result code.
308
508
  #
309
- # @param [Exception] error The error that occurred.
509
+ # @param error [Exception] The error that occurred.
510
+ # @return [Integer] The result code for the execution.
310
511
  #
311
512
  def call(error)
312
- @terminal.puts(cause_string(error.cause))
313
- @terminal.puts(context_string(error), :bold)
314
- -1
513
+ cause = error
514
+ case error
515
+ when ContextualError
516
+ cause = error.cause
517
+ @terminal.puts(cause_string(cause))
518
+ @terminal.puts(context_string(error), :bold)
519
+ when ::Interrupt
520
+ @terminal.puts
521
+ @terminal.puts("INTERRUPTED", :bold)
522
+ else
523
+ @terminal.puts(cause_string(error))
524
+ end
525
+ exit_code_for(cause)
315
526
  end
316
527
 
317
528
  private
318
529
 
530
+ def exit_code_for(error)
531
+ case error
532
+ when ArgParsingError
533
+ 2
534
+ when NotRunnableError
535
+ 126
536
+ when ::Interrupt
537
+ 130
538
+ else
539
+ 1
540
+ end
541
+ end
542
+
319
543
  def cause_string(cause)
320
544
  lines = ["#{cause.class}: #{cause.message}"]
321
545
  cause.backtrace.each_with_index.reverse_each do |bt, i|
@@ -327,7 +551,7 @@ module Toys
327
551
  def context_string(error)
328
552
  lines = [
329
553
  error.banner || "Unexpected error!",
330
- " #{error.cause.class}: #{error.cause.message}"
554
+ " #{error.cause.class}: #{error.cause.message}",
331
555
  ]
332
556
  if error.config_path
333
557
  lines << " in config file: #{error.config_path}:#{error.config_line}"
@@ -342,29 +566,44 @@ module Toys
342
566
  end
343
567
  end
344
568
 
569
+ ##
570
+ # A Completion that implements the default algorithm for a CLI. This
571
+ # algorithm simply determines the tool and uses its completion.
572
+ #
573
+ class DefaultCompletion < Completion::Base
574
+ ##
575
+ # Returns candidates for the current completion.
576
+ #
577
+ # @param context [Toys::Completion::Context] the current completion
578
+ # context including the string fragment.
579
+ # @return [Array<Toys::Completion::Candidate>] an array of candidates
580
+ #
581
+ def call(context)
582
+ context.tool.completion.call(context)
583
+ end
584
+ end
585
+
345
586
  class << self
346
587
  ##
347
588
  # Returns a default set of middleware that may be used as a starting
348
589
  # point for a typical CLI. This set includes the following in order:
349
590
  #
350
591
  # * {Toys::StandardMiddleware::SetDefaultDescriptions} providing
351
- # defaults for description fields
352
- # * {Toys::StandardMiddleware::ShowHelp} adding the `--help` flag
592
+ # defaults for description fields.
593
+ # * {Toys::StandardMiddleware::ShowHelp} adding the `--help` flag and
594
+ # providing default behavior for namespaces.
353
595
  # * {Toys::StandardMiddleware::HandleUsageErrors}
354
- # * {Toys::StandardMiddleware::ShowHelp} providing default behavior for
355
- # namespaces
356
596
  # * {Toys::StandardMiddleware::AddVerbosityFlags} adding the `--verbose`
357
- # and `--quiet` flags for managing the logger level
597
+ # and `--quiet` flags for managing the logger level.
358
598
  #
359
- # @return [Array]
599
+ # @return [Array<Toys::Middleware>]
360
600
  #
361
601
  def default_middleware_stack
362
602
  [
363
603
  [:set_default_descriptions],
364
- [:show_help, help_flags: true],
604
+ [:show_help, help_flags: true, fallback_execution: true],
365
605
  [:handle_usage_errors],
366
- [:show_help, fallback_execution: true],
367
- [:add_verbosity_flags]
606
+ [:add_verbosity_flags],
368
607
  ]
369
608
  end
370
609
 
@@ -372,40 +611,42 @@ module Toys
372
611
  # Returns a default ModuleLookup for mixins that points at the
373
612
  # StandardMixins module.
374
613
  #
375
- # @return [Toys::Utils::ModuleLookup]
614
+ # @return [Toys::ModuleLookup]
376
615
  #
377
616
  def default_mixin_lookup
378
- Utils::ModuleLookup.new.add_path("toys/standard_mixins")
617
+ ModuleLookup.new.add_path("toys/standard_mixins")
379
618
  end
380
619
 
381
620
  ##
382
621
  # Returns a default ModuleLookup for middleware that points at the
383
622
  # StandardMiddleware module.
384
623
  #
385
- # @return [Toys::Utils::ModuleLookup]
624
+ # @return [Toys::ModuleLookup]
386
625
  #
387
626
  def default_middleware_lookup
388
- Utils::ModuleLookup.new.add_path("toys/standard_middleware")
627
+ ModuleLookup.new.add_path("toys/standard_middleware")
389
628
  end
390
629
 
391
630
  ##
392
631
  # Returns a default empty ModuleLookup for templates.
393
632
  #
394
- # @return [Toys::Utils::ModuleLookup]
633
+ # @return [Toys::ModuleLookup]
395
634
  #
396
635
  def default_template_lookup
397
- Utils::ModuleLookup.new
636
+ ModuleLookup.new
398
637
  end
399
638
 
400
639
  ##
401
- # Returns a default logger that logs to `$stderr`.
640
+ # Returns a default logger that writes formatted logs to a given stream.
402
641
  #
403
- # @param [IO] stream Stream to write to. Defaults to `$stderr`.
642
+ # @param output [IO] The stream to output to (defaults to `$stderr`)
404
643
  # @return [Logger]
405
644
  #
406
- def default_logger(stream = $stderr)
407
- logger = ::Logger.new(stream)
408
- terminal = Utils::Terminal.new(output: stream)
645
+ def default_logger(output: nil)
646
+ require "toys/utils/terminal"
647
+ output ||= $stderr
648
+ logger = ::Logger.new(output)
649
+ terminal = Utils::Terminal.new(output: output)
409
650
  logger.formatter = proc do |severity, time, _progname, msg|
410
651
  msg_str =
411
652
  case msg
@@ -426,7 +667,7 @@ module Toys
426
667
 
427
668
  def format_log(terminal, time, severity, msg)
428
669
  timestr = time.strftime("%Y-%m-%d %H:%M:%S")
429
- header = format("[%s %5s]", timestr, severity)
670
+ header = format("[%<time>s %<sev>5s]", time: timestr, sev: severity)
430
671
  styled_header =
431
672
  case severity
432
673
  when "FATAL"