toys-core 0.11.3 → 0.12.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -2
  3. data/README.md +1 -1
  4. data/lib/toys-core.rb +4 -1
  5. data/lib/toys/acceptor.rb +3 -3
  6. data/lib/toys/arg_parser.rb +6 -7
  7. data/lib/toys/cli.rb +44 -14
  8. data/lib/toys/compat.rb +19 -22
  9. data/lib/toys/completion.rb +3 -1
  10. data/lib/toys/context.rb +2 -2
  11. data/lib/toys/core.rb +1 -1
  12. data/lib/toys/dsl/base.rb +85 -0
  13. data/lib/toys/dsl/flag.rb +3 -3
  14. data/lib/toys/dsl/flag_group.rb +7 -7
  15. data/lib/toys/dsl/internal.rb +206 -0
  16. data/lib/toys/dsl/positional_arg.rb +3 -3
  17. data/lib/toys/dsl/tool.rb +174 -216
  18. data/lib/toys/errors.rb +1 -0
  19. data/lib/toys/flag.rb +15 -18
  20. data/lib/toys/flag_group.rb +5 -4
  21. data/lib/toys/input_file.rb +4 -4
  22. data/lib/toys/loader.rb +189 -50
  23. data/lib/toys/middleware.rb +1 -1
  24. data/lib/toys/mixin.rb +2 -2
  25. data/lib/toys/positional_arg.rb +3 -3
  26. data/lib/toys/settings.rb +900 -0
  27. data/lib/toys/source_info.rb +121 -18
  28. data/lib/toys/standard_middleware/apply_config.rb +5 -4
  29. data/lib/toys/standard_middleware/set_default_descriptions.rb +18 -18
  30. data/lib/toys/standard_middleware/show_help.rb +17 -5
  31. data/lib/toys/standard_mixins/bundler.rb +7 -2
  32. data/lib/toys/standard_mixins/exec.rb +22 -15
  33. data/lib/toys/standard_mixins/git_cache.rb +48 -0
  34. data/lib/toys/standard_mixins/xdg.rb +56 -0
  35. data/lib/toys/template.rb +2 -2
  36. data/lib/toys/{tool.rb → tool_definition.rb} +100 -41
  37. data/lib/toys/utils/exec.rb +12 -10
  38. data/lib/toys/utils/gems.rb +48 -14
  39. data/lib/toys/utils/git_cache.rb +184 -0
  40. data/lib/toys/utils/help_text.rb +90 -34
  41. data/lib/toys/utils/terminal.rb +1 -1
  42. data/lib/toys/utils/xdg.rb +293 -0
  43. metadata +15 -8
data/lib/toys/errors.rb CHANGED
@@ -54,6 +54,7 @@ module Toys
54
54
  @config_line = config_line
55
55
  @tool_name = tool_name
56
56
  @tool_args = tool_args
57
+ set_backtrace(cause.backtrace)
57
58
  end
58
59
 
59
60
  attr_reader :cause
data/lib/toys/flag.rb CHANGED
@@ -85,11 +85,11 @@ module Toys
85
85
  # true.
86
86
  # @param group [Toys::FlagGroup] Group containing this flag.
87
87
  # @param desc [String,Array<String>,Toys::WrappableString] Short
88
- # description for the flag. See {Toys::Tool#desc=} for a description of
89
- # allowed formats. Defaults to the empty string.
88
+ # description for the flag. See {Toys::ToolDefinition#desc} for a
89
+ # description of allowed formats. Defaults to the empty string.
90
90
  # @param long_desc [Array<String,Array<String>,Toys::WrappableString>]
91
- # Long description for the flag. See {Toys::Tool#long_desc=} for a
92
- # description of allowed formats. Defaults to the empty array.
91
+ # Long description for the flag. See {Toys::ToolDefinition#long_desc}
92
+ # for a description of allowed formats. Defaults to the empty array.
93
93
  # @param display_name [String] A display name for this flag, used in help
94
94
  # text and error messages.
95
95
  # @param used_flags [Array<String>] An array of flags already in use.
@@ -368,7 +368,7 @@ module Toys
368
368
  key_str = key.to_s
369
369
  flag_str =
370
370
  if key_str.length == 1
371
- "-#{key_str}" if key_str =~ /[a-zA-Z0-9\?]/
371
+ "-#{key_str}" if key_str =~ /[a-zA-Z0-9?]/
372
372
  elsif key_str.length > 1
373
373
  key_str = key_str.downcase.tr("_", "-").gsub(/[^a-z0-9-]/, "").sub(/^-+/, "")
374
374
  "--#{key_str}" unless key_str.empty?
@@ -455,23 +455,19 @@ module Toys
455
455
  #
456
456
  def initialize(str)
457
457
  case str
458
- when /\A(-([\?\w]))\z/
458
+ when /\A(-([?\w]))\z/
459
459
  setup(str, $1, nil, $1, $2, :short, nil, nil, nil, nil)
460
- when /\A(-([\?\w]))( ?)\[(\w+)\]\z/
461
- setup(str, $1, nil, $1, $2, :short, :value, :optional, $3, $4)
462
- when /\A(-([\?\w]))\[( )(\w+)\]\z/
463
- setup(str, $1, nil, $1, $2, :short, :value, :optional, $3, $4)
464
- when /\A(-([\?\w]))( ?)(\w+)\z/
460
+ when /\A(-([?\w]))(?:( ?)\[|\[( ))(\w+)\]\z/
461
+ setup(str, $1, nil, $1, $2, :short, :value, :optional, $3 || $4, $5)
462
+ when /\A(-([?\w]))( ?)(\w+)\z/
465
463
  setup(str, $1, nil, $1, $2, :short, :value, :required, $3, $4)
466
- when /\A--\[no-\](\w[\?\w-]*)\z/
464
+ when /\A--\[no-\](\w[?\w-]*)\z/
467
465
  setup(str, "--#{$1}", "--no-#{$1}", str, $1, :long, :boolean, nil, nil, nil)
468
- when /\A(--(\w[\?\w-]*))\z/
466
+ when /\A(--(\w[?\w-]*))\z/
469
467
  setup(str, $1, nil, $1, $2, :long, nil, nil, nil, nil)
470
- when /\A(--(\w[\?\w-]*))([= ])\[(\w+)\]\z/
471
- setup(str, $1, nil, $1, $2, :long, :value, :optional, $3, $4)
472
- when /\A(--(\w[\?\w-]*))\[([= ])(\w+)\]\z/
473
- setup(str, $1, nil, $1, $2, :long, :value, :optional, $3, $4)
474
- when /\A(--(\w[\?\w-]*))([= ])(\w+)\z/
468
+ when /\A(--(\w[?\w-]*))(?:([= ])\[|\[([= ]))(\w+)\]\z/
469
+ setup(str, $1, nil, $1, $2, :long, :value, :optional, $3 || $4, $5)
470
+ when /\A(--(\w[?\w-]*))([= ])(\w+)\z/
475
471
  setup(str, $1, nil, $1, $2, :long, :value, :required, $3, $4)
476
472
  else
477
473
  raise ToolDefinitionError, "Illegal flag: #{str.inspect}"
@@ -731,6 +727,7 @@ module Toys
731
727
  # @param include_negative [Boolean] Whether to include `--no-*` forms.
732
728
  #
733
729
  def initialize(flag:, include_short: true, include_long: true, include_negative: true)
730
+ super()
734
731
  @flag = flag
735
732
  @include_short = include_short
736
733
  @include_long = include_long
@@ -17,11 +17,12 @@ module Toys
17
17
  #
18
18
  # @param type [Symbol] The type of group. Default is `:optional`.
19
19
  # @param desc [String,Array<String>,Toys::WrappableString] Short
20
- # description for the group. See {Toys::Tool#desc=} for a description
21
- # of allowed formats. Defaults to `"Flags"`.
20
+ # description for the group. See {Toys::ToolDefinition#desc} for a
21
+ # description of allowed formats. Defaults to `"Flags"`.
22
22
  # @param long_desc [Array<String,Array<String>,Toys::WrappableString>]
23
- # Long description for the flag group. See {Toys::Tool#long_desc=} for
24
- # a description of allowed formats. Defaults to the empty array.
23
+ # Long description for the flag group. See
24
+ # {Toys::ToolDefinition#long_desc} for a description of allowed
25
+ # formats. Defaults to the empty array.
25
26
  # @param name [String,Symbol,nil] The name of the group, or nil for no
26
27
  # name.
27
28
  # @return [Toys::FlagGroup::Base] A flag group of the correct subclass.
@@ -11,11 +11,11 @@ module Toys::InputFile # rubocop:disable Style/ClassAndModuleChildren
11
11
  end
12
12
 
13
13
  ## @private
14
- def self.evaluate(tool_class, remaining_words, source)
14
+ def self.evaluate(tool_class, words, priority, remaining_words, source, loader)
15
15
  namespace = ::Module.new
16
16
  namespace.module_eval do
17
17
  include ::Toys::Context::Key
18
- @tool_class = tool_class
18
+ @__tool_class = tool_class
19
19
  end
20
20
  path = source.source_path
21
21
  basename = ::File.basename(path).tr(".-", "_").gsub(/\W/, "")
@@ -23,7 +23,7 @@ module Toys::InputFile # rubocop:disable Style/ClassAndModuleChildren
23
23
  str = build_eval_string(name, ::IO.read(path))
24
24
  if str
25
25
  const_set(name, namespace)
26
- ::Toys::DSL::Tool.prepare(tool_class, remaining_words, source) do
26
+ ::Toys::DSL::Internal.prepare(tool_class, words, priority, remaining_words, source, loader) do
27
27
  ::Toys::ContextualError.capture_path("Error while loading Toys config!", path) do
28
28
  # rubocop:disable Security/Eval
29
29
  eval(str, __binding, path, -2)
@@ -39,7 +39,7 @@ module Toys::InputFile # rubocop:disable Style/ClassAndModuleChildren
39
39
  return nil if index.nil?
40
40
  "#{string[0, index]}\n" \
41
41
  "module #{module_name}\n" \
42
- "@tool_class.class_eval do\n" \
42
+ "@__tool_class.class_eval do\n" \
43
43
  "#{string[index..-1]}\n" \
44
44
  "end\n" \
45
45
  "end\n"
data/lib/toys/loader.rb CHANGED
@@ -54,7 +54,8 @@ module Toys
54
54
  extra_delimiters: "",
55
55
  mixin_lookup: nil,
56
56
  middleware_lookup: nil,
57
- template_lookup: nil
57
+ template_lookup: nil,
58
+ git_cache: nil
58
59
  )
59
60
  if index_file_name && ::File.extname(index_file_name) != ".rb"
60
61
  raise ::ArgumentError, "Illegal index file name #{index_file_name.inspect}"
@@ -71,30 +72,82 @@ module Toys
71
72
  @loading_started = false
72
73
  @worklist = []
73
74
  @tool_data = {}
75
+ @roots_by_priority = {}
74
76
  @max_priority = @min_priority = 0
75
77
  @stop_priority = BASE_PRIORITY
76
78
  @min_loaded_priority = 999_999
77
79
  @middleware_stack = Middleware.stack(middleware_stack)
78
80
  @delimiter_handler = DelimiterHandler.new(extra_delimiters)
81
+ @git_cache = git_cache
79
82
  get_tool([], BASE_PRIORITY)
80
83
  end
81
84
 
82
85
  ##
83
86
  # Add a configuration file/directory to the loader.
84
87
  #
85
- # @param paths [String,Array<String>] One or more paths to add.
88
+ # @param path [String] A single path to add.
86
89
  # @param high_priority [Boolean] If true, add this path at the top of the
87
90
  # priority list. Defaults to false, indicating the new path should be
88
91
  # at the bottom of the priority list.
92
+ # @param source_name [String] A custom name for the root source. Optional.
93
+ # @param context_directory [String,nil,:path,:parent] The context directory
94
+ # for tools loaded from this path. You can pass a directory path as a
95
+ # string, `:path` to denote the given path, `:parent` to denote the
96
+ # given path's parent directory, or `nil` to denote no context.
97
+ # Defaults to `:parent`.
89
98
  # @return [self]
90
99
  #
91
- def add_path(paths, high_priority: false)
92
- paths = Array(paths)
100
+ def add_path(path,
101
+ high_priority: false,
102
+ source_name: nil,
103
+ context_directory: :parent)
93
104
  @mutex.synchronize do
94
105
  raise "Cannot add a path after tool loading has started" if @loading_started
95
106
  priority = high_priority ? (@max_priority += 1) : (@min_priority -= 1)
96
- paths.each do |path|
97
- source = SourceInfo.create_path_root(path, @data_dir_name, @lib_dir_name)
107
+ source = SourceInfo.create_path_root(path, priority,
108
+ context_directory: context_directory,
109
+ data_dir_name: @data_dir_name,
110
+ lib_dir_name: @lib_dir_name,
111
+ source_name: source_name)
112
+ @roots_by_priority[priority] = source
113
+ @worklist << [source, [], priority]
114
+ end
115
+ self
116
+ end
117
+
118
+ ##
119
+ # Add a set of configuration files/directories from a common directory to
120
+ # the loader. The set of paths will be added at the same priority level and
121
+ # will share a root.
122
+ #
123
+ # @param root_path [String] A root path to be seen as the root source. This
124
+ # should generally be a directory containing the paths to add.
125
+ # @param relative_paths [String,Array<String>] One or more paths to add, as
126
+ # relative paths from the common root.
127
+ # @param high_priority [Boolean] If true, add the paths at the top of the
128
+ # priority list. Defaults to false, indicating the new paths should be
129
+ # at the bottom of the priority list.
130
+ # @param context_directory [String,nil,:path,:parent] The context directory
131
+ # for tools loaded from this path. You can pass a directory path as a
132
+ # string, `:path` to denote the given root path, `:parent` to denote
133
+ # the given root path's parent directory, or `nil` to denote no context.
134
+ # Defaults to `:path`.
135
+ # @return [self]
136
+ #
137
+ def add_path_set(root_path, relative_paths,
138
+ high_priority: false,
139
+ context_directory: :path)
140
+ relative_paths = Array(relative_paths)
141
+ @mutex.synchronize do
142
+ raise "Cannot add a path after tool loading has started" if @loading_started
143
+ priority = high_priority ? (@max_priority += 1) : (@min_priority -= 1)
144
+ root_source = SourceInfo.create_path_root(root_path, priority,
145
+ context_directory: context_directory,
146
+ data_dir_name: @data_dir_name,
147
+ lib_dir_name: @lib_dir_name)
148
+ @roots_by_priority[priority] = root_source
149
+ relative_paths.each do |path, individual_name|
150
+ source = root_source.relative_child(path, source_name: individual_name)
98
151
  @worklist << [source, [], priority]
99
152
  end
100
153
  end
@@ -107,19 +160,65 @@ module Toys
107
160
  # @param high_priority [Boolean] If true, add this block at the top of the
108
161
  # priority list. Defaults to false, indicating the block should be at
109
162
  # the bottom of the priority list.
110
- # @param name [String] The source name that will be shown in documentation
111
- # for tools defined in this block. If omitted, a default unique string
112
- # will be generated.
163
+ # @param source_name [String] The source name that will be shown in
164
+ # documentation for tools defined in this block. If omitted, a default
165
+ # unique string will be generated.
113
166
  # @param block [Proc] The block of configuration, executed in the context
114
167
  # of the tool DSL {Toys::DSL::Tool}.
168
+ # @param context_directory [String,nil] The context directory for tools
169
+ # loaded from this block. You can pass a directory path as a string, or
170
+ # `nil` to denote no context. Defaults to `nil`.
115
171
  # @return [self]
116
172
  #
117
- def add_block(high_priority: false, name: nil, &block)
118
- name ||= "(Code block #{block.object_id})"
173
+ def add_block(high_priority: false,
174
+ source_name: nil,
175
+ context_directory: nil,
176
+ &block)
119
177
  @mutex.synchronize do
120
178
  raise "Cannot add a block after tool loading has started" if @loading_started
121
179
  priority = high_priority ? (@max_priority += 1) : (@min_priority -= 1)
122
- source = SourceInfo.create_proc_root(block, name, @data_dir_name, @lib_dir_name)
180
+ source = SourceInfo.create_proc_root(block, priority,
181
+ context_directory: context_directory,
182
+ source_name: source_name,
183
+ data_dir_name: @data_dir_name,
184
+ lib_dir_name: @lib_dir_name)
185
+ @roots_by_priority[priority] = source
186
+ @worklist << [source, [], priority]
187
+ end
188
+ self
189
+ end
190
+
191
+ ##
192
+ # Add a configuration git source to the loader.
193
+ #
194
+ # @param git_remote [String] The git repo URL
195
+ # @param git_path [String] The path to the relevant file or directory in
196
+ # the repo. Specify the empty string to use the entire repo.
197
+ # @param git_commit [String] The git ref (i.e. SHA, tag, or branch name)
198
+ # @param high_priority [Boolean] If true, add this path at the top of the
199
+ # priority list. Defaults to false, indicating the new path should be
200
+ # at the bottom of the priority list.
201
+ # @param update [Boolean] If the commit is not a SHA, pulls any updates
202
+ # from the remote. Defaults to false, which uses a local cache and does
203
+ # not update if the commit has been fetched previously.
204
+ # @param context_directory [String,nil] The context directory for tools
205
+ # loaded from this source. You can pass a directory path as a string,
206
+ # or `nil` to denote no context. Defaults to `nil`.
207
+ # @return [self]
208
+ #
209
+ def add_git(git_remote, git_path, git_commit,
210
+ high_priority: false,
211
+ update: false,
212
+ context_directory: nil)
213
+ @mutex.synchronize do
214
+ raise "Cannot add a git source after tool loading has started" if @loading_started
215
+ priority = high_priority ? (@max_priority += 1) : (@min_priority -= 1)
216
+ path = git_cache.find(git_remote, path: git_path, commit: git_commit, update: update)
217
+ source = SourceInfo.create_git_root(git_remote, git_path, git_commit, path, priority,
218
+ context_directory: context_directory,
219
+ data_dir_name: @data_dir_name,
220
+ lib_dir_name: @lib_dir_name)
221
+ @roots_by_priority[priority] = source
123
222
  @worklist << [source, [], priority]
124
223
  end
125
224
  self
@@ -136,7 +235,7 @@ module Toys
136
235
  # that are not part of the tool name and should be passed as tool args.
137
236
  #
138
237
  # @param args [Array<String>] Command line arguments
139
- # @return [Array(Toys::Tool,Array<String>)]
238
+ # @return [Array(Toys::ToolDefinition,Array<String>)]
140
239
  #
141
240
  def lookup(args)
142
241
  orig_prefix, args = @delimiter_handler.find_orig_prefix(args)
@@ -157,13 +256,13 @@ module Toys
157
256
  # the given name, returns `nil`.
158
257
  #
159
258
  # @param words [Array<String>] The tool name
160
- # @return [Toys::Tool] if the tool was found
259
+ # @return [Toys::ToolDefinition] if the tool was found
161
260
  # @return [nil] if no such tool exists
162
261
  #
163
262
  def lookup_specific(words)
164
263
  words = @delimiter_handler.split_path(words.first) if words.size == 1
165
264
  load_for_prefix(words)
166
- tool = get_tool_data(words).cur_definition
265
+ tool = get_tool_data(words, false)&.cur_definition
167
266
  finish_definitions_in_tree(words) if tool
168
267
  tool
169
268
  end
@@ -177,7 +276,7 @@ module Toys
177
276
  # rather than just the immediate children (the default)
178
277
  # @param include_hidden [Boolean] If true, include hidden subtools,
179
278
  # e.g. names beginning with underscores.
180
- # @return [Array<Toys::Tool>] An array of subtools.
279
+ # @return [Array<Toys::ToolDefinition>] An array of subtools.
181
280
  #
182
281
  def list_subtools(words, recursive: false, include_hidden: false)
183
282
  load_for_prefix(words)
@@ -234,8 +333,8 @@ module Toys
234
333
  #
235
334
  # @private
236
335
  #
237
- def get_tool(words, priority)
238
- get_tool_data(words).get_tool(priority, self)
336
+ def get_tool(words, priority, tool_class = nil)
337
+ get_tool_data(words, true).get_tool(priority, self, tool_class)
239
338
  end
240
339
 
241
340
  ##
@@ -248,7 +347,7 @@ module Toys
248
347
  # @private
249
348
  #
250
349
  def activate_tool(words, priority)
251
- get_tool_data(words).activate_tool(priority, self)
350
+ get_tool_data(words, true).activate_tool(priority, self)
252
351
  end
253
352
 
254
353
  ##
@@ -267,10 +366,11 @@ module Toys
267
366
  #
268
367
  # @private
269
368
  #
270
- def build_tool(words, priority)
369
+ def build_tool(words, priority, tool_class = nil)
271
370
  parent = words.empty? ? nil : get_tool(words.slice(0..-2), priority)
272
371
  middleware_stack = parent ? parent.subtool_middleware_stack : @middleware_stack
273
- Tool.new(self, parent, words, priority, middleware_stack, @middleware_lookup)
372
+ ToolDefinition.new(parent, words, priority, @roots_by_priority[priority],
373
+ middleware_stack, @middleware_lookup, tool_class)
274
374
  end
275
375
 
276
376
  ##
@@ -335,12 +435,31 @@ module Toys
335
435
  # @private
336
436
  #
337
437
  def load_path(parent_source, path, words, remaining_words, priority)
438
+ if parent_source.git_remote
439
+ raise LoaderError,
440
+ "Git source #{parent_source.source_name} tried to load from the local file system"
441
+ end
338
442
  source = parent_source.absolute_child(path)
339
443
  @mutex.synchronize do
340
444
  load_validated_path(source, words, remaining_words, priority)
341
445
  end
342
446
  end
343
447
 
448
+ ##
449
+ # Load configuration from the given git remote. This is called from the
450
+ # `load_git` directive in the DSL.
451
+ #
452
+ # @private
453
+ #
454
+ def load_git(parent_source, git_remote, git_path, git_commit, words, remaining_words, priority,
455
+ update: false)
456
+ path = git_cache.find(git_remote, path: git_path, commit: git_commit, update: update)
457
+ source = parent_source.git_child(git_remote, git_path, git_commit, path)
458
+ @mutex.synchronize do
459
+ load_validated_path(source, words, remaining_words, priority)
460
+ end
461
+ end
462
+
344
463
  ##
345
464
  # Load a subtool block. Called from the `tool` directive in the DSL.
346
465
  #
@@ -353,6 +472,18 @@ module Toys
353
472
  end
354
473
  end
355
474
 
475
+ ##
476
+ # Get a GitCache.
477
+ #
478
+ # @private
479
+ #
480
+ def git_cache
481
+ @git_cache ||= begin
482
+ require "toys/utils/git_cache"
483
+ Utils::GitCache.new
484
+ end
485
+ end
486
+
356
487
  ##
357
488
  # Determine the next setting for remaining_words, given a word.
358
489
  #
@@ -381,8 +512,10 @@ module Toys
381
512
  result
382
513
  end
383
514
 
384
- def get_tool_data(words)
385
- @mutex.synchronize { @tool_data[words] ||= ToolData.new(words) }
515
+ def get_tool_data(words, create)
516
+ @mutex.synchronize do
517
+ create ? (@tool_data[words] ||= ToolData.new(words)) : @tool_data[words]
518
+ end
386
519
  end
387
520
 
388
521
  ##
@@ -403,7 +536,7 @@ module Toys
403
536
  if remaining_words
404
537
  update_min_loaded_priority(priority)
405
538
  tool_class = get_tool(words, priority).tool_class
406
- DSL::Tool.prepare(tool_class, remaining_words, source) do
539
+ DSL::Internal.prepare(tool_class, words, priority, remaining_words, source, self) do
407
540
  ContextualError.capture("Error while loading Toys config!") do
408
541
  tool_class.class_eval(&source.source_proc)
409
542
  end
@@ -425,7 +558,7 @@ module Toys
425
558
  if source.source_type == :file
426
559
  update_min_loaded_priority(priority)
427
560
  tool_class = get_tool(words, priority).tool_class
428
- InputFile.evaluate(tool_class, remaining_words, source)
561
+ InputFile.evaluate(tool_class, words, priority, remaining_words, source, self)
429
562
  else
430
563
  do_preload(source.source_path)
431
564
  load_index_in(source, words, remaining_words, priority)
@@ -467,16 +600,20 @@ module Toys
467
600
  if @preload_dir_name
468
601
  preload_dir = ::File.join(path, @preload_dir_name)
469
602
  if ::File.directory?(preload_dir) && ::File.readable?(preload_dir)
470
- ::Dir.entries(preload_dir).each do |child|
471
- next unless ::File.extname(child) == ".rb"
472
- preload_file = ::File.join(preload_dir, child)
473
- next if !::File.file?(preload_file) || !::File.readable?(preload_file)
474
- require preload_file
475
- end
603
+ preload_dir_contents(preload_dir)
476
604
  end
477
605
  end
478
606
  end
479
607
 
608
+ def preload_dir_contents(preload_dir)
609
+ ::Dir.entries(preload_dir).each do |child|
610
+ next unless ::File.extname(child) == ".rb"
611
+ preload_file = ::File.join(preload_dir, child)
612
+ next if !::File.file?(preload_file) || !::File.readable?(preload_file)
613
+ require preload_file
614
+ end
615
+ end
616
+
480
617
  def sort_tools_by_name(tools)
481
618
  tools.sort! do |a, b|
482
619
  a = a.full_name
@@ -520,7 +657,7 @@ module Toys
520
657
  class ToolData
521
658
  # @private
522
659
  def initialize(words)
523
- @words = words
660
+ @words = validate_words(words)
524
661
  @definitions = {}
525
662
  @top_priority = @active_priority = nil
526
663
  @mutex = ::Monitor.new
@@ -537,12 +674,15 @@ module Toys
537
674
  end
538
675
 
539
676
  # @private
540
- def get_tool(priority, loader)
677
+ def get_tool(priority, loader, tool_class = nil)
541
678
  @mutex.synchronize do
542
679
  if @top_priority.nil? || @top_priority < priority
543
680
  @top_priority = priority
544
681
  end
545
- @definitions[priority] ||= loader.build_tool(@words, priority)
682
+ if tool_class && @definitions.include?(priority)
683
+ raise ToolDefinitionError, "Tool already defined for #{@words.inspect}"
684
+ end
685
+ @definitions[priority] ||= loader.build_tool(@words, priority, tool_class)
546
686
  end
547
687
  end
548
688
 
@@ -558,6 +698,14 @@ module Toys
558
698
 
559
699
  private
560
700
 
701
+ def validate_words(words)
702
+ words.each do |word|
703
+ if /[[:cntrl:] #"$&'()*;<>\[\\\]\^`{|}]/.match(word)
704
+ raise ToolDefinitionError, "Illegal characters in name #{word.inspect}"
705
+ end
706
+ end
707
+ end
708
+
561
709
  def top_definition
562
710
  @top_priority ? @definitions[@top_priority] : nil
563
711
  end
@@ -573,32 +721,23 @@ module Toys
573
721
  # @private
574
722
  #
575
723
  class DelimiterHandler
576
- ## @private
577
- ALLOWED_DELIMITERS = %r{^[\./:]*$}.freeze
578
- private_constant :ALLOWED_DELIMITERS
579
-
580
- ## @private
581
724
  def initialize(extra_delimiters)
582
- unless ALLOWED_DELIMITERS =~ extra_delimiters
725
+ unless %r{^[[:space:]./:]*$}.match?(extra_delimiters)
583
726
  raise ::ArgumentError, "Illegal delimiters in #{extra_delimiters.inspect}"
584
727
  end
585
728
  chars = ::Regexp.escape(extra_delimiters.chars.uniq.join)
586
- @extra_delimiters = chars.empty? ? nil : ::Regexp.new("[#{chars}]")
729
+ @delimiters = ::Regexp.new("[[:space:]#{chars}]")
587
730
  end
588
731
 
589
- ## @private
590
732
  def split_path(str)
591
- @extra_delimiters ? str.split(@extra_delimiters) : [str]
733
+ str.split(@delimiters)
592
734
  end
593
735
 
594
- ## @private
595
736
  def find_orig_prefix(args)
596
- if @extra_delimiters
597
- first_split = (args.first || "").split(@extra_delimiters)
598
- if first_split.size > 1
599
- args = first_split + args.slice(1..-1)
600
- return [first_split, args]
601
- end
737
+ first_split = (args.first || "").split(@delimiters)
738
+ if first_split.size > 1
739
+ args = first_split + args.slice(1..-1)
740
+ return [first_split, args]
602
741
  end
603
742
  orig_prefix = args.take_while { |arg| !arg.start_with?("-") }
604
743
  [orig_prefix, args]