toys-core 0.7.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
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"