toys-core 0.8.1 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -49,8 +49,8 @@ module Toys
49
49
  @display_name = display_name
50
50
  @desc = desc
51
51
  @long_desc = long_desc || []
52
- accept(acceptor)
53
- complete(completion)
52
+ accept(acceptor, **{})
53
+ complete(completion, **{})
54
54
  end
55
55
 
56
56
  ##
@@ -65,9 +65,7 @@ module Toys
65
65
  # @return [self]
66
66
  #
67
67
  def accept(spec = nil, **options, &block)
68
- @acceptor_spec = spec
69
- @acceptor_options = options
70
- @acceptor_block = block
68
+ @acceptor = Acceptor.scalarize_spec(spec, options, block)
71
69
  self
72
70
  end
73
71
 
@@ -94,9 +92,7 @@ module Toys
94
92
  # @return [self]
95
93
  #
96
94
  def complete(spec = nil, **options, &block)
97
- @completion_spec = spec
98
- @completion_options = options
99
- @completion_block = block
95
+ @completion = Completion.scalarize_spec(spec, options, block)
100
96
  self
101
97
  end
102
98
 
@@ -178,31 +174,22 @@ module Toys
178
174
 
179
175
  ## @private
180
176
  def _add_required_to(tool, key)
181
- acceptor = tool.scalar_acceptor(@acceptor_spec, @acceptor_options, &@acceptor_block)
182
- completion = tool.scalar_completion(@completion_spec, @completion_options,
183
- &@completion_block)
184
177
  tool.add_required_arg(key,
185
- accept: acceptor, complete: completion,
178
+ accept: @acceptor, complete: @completion,
186
179
  display_name: @display_name, desc: @desc, long_desc: @long_desc)
187
180
  end
188
181
 
189
182
  ## @private
190
183
  def _add_optional_to(tool, key)
191
- acceptor = tool.scalar_acceptor(@acceptor_spec, @acceptor_options, &@acceptor_block)
192
- completion = tool.scalar_completion(@completion_spec, @completion_options,
193
- &@completion_block)
194
184
  tool.add_optional_arg(key,
195
- accept: acceptor, default: @default, complete: completion,
185
+ accept: @acceptor, default: @default, complete: @completion,
196
186
  display_name: @display_name, desc: @desc, long_desc: @long_desc)
197
187
  end
198
188
 
199
189
  ## @private
200
190
  def _set_remaining_on(tool, key)
201
- acceptor = tool.scalar_acceptor(@acceptor_spec, @acceptor_options, &@acceptor_block)
202
- completion = tool.scalar_completion(@completion_spec, @completion_options,
203
- &@completion_block)
204
191
  tool.set_remaining_args(key,
205
- accept: acceptor, default: @default, complete: completion,
192
+ accept: @acceptor, default: @default, complete: @completion,
206
193
  display_name: @display_name, desc: @desc, long_desc: @long_desc)
207
194
  end
208
195
  end
data/lib/toys/dsl/tool.rb CHANGED
@@ -57,7 +57,7 @@ module Toys
57
57
  module Tool
58
58
  ## @private
59
59
  def method_added(_meth)
60
- DSL::Tool.current_tool(self, true)&.check_definition_state
60
+ DSL::Tool.current_tool(self, true)&.check_definition_state(is_method: true)
61
61
  end
62
62
 
63
63
  ##
@@ -273,7 +273,7 @@ module Toys
273
273
  #
274
274
  def completion(name, spec = nil, **options, &block)
275
275
  cur_tool = DSL::Tool.current_tool(self, false)
276
- cur_tool&.add_completion(name, spec, options, &block)
276
+ cur_tool&.add_completion(name, spec, **options, &block)
277
277
  self
278
278
  end
279
279
 
@@ -297,6 +297,16 @@ module Toys
297
297
  # end
298
298
  # end
299
299
  #
300
+ # The following example defines a tool that runs one of its subtools.
301
+ #
302
+ # tool "test", runs: ["test", "unit"] do
303
+ # tool "unit" do
304
+ # def run
305
+ # puts "Running unit tests"
306
+ # end
307
+ # end
308
+ # end
309
+ #
300
310
  # @param words [String,Array<String>] The name of the subtool
301
311
  # @param if_defined [:combine,:reset,:ignore] What to do if a definition
302
312
  # already exists for this tool. Possible values are `:combine` (the
@@ -304,10 +314,14 @@ module Toys
304
314
  # existing definition, `:reset` indicating the earlier definition
305
315
  # should be reset and the new definition applied instead, or
306
316
  # `:ignore` indicating the new definition should be ignored.
317
+ # @param delegate_to [String,Array<String>] Optional. This tool should
318
+ # delegate to another tool, specified by the full path. This path may
319
+ # be given as an array of strings, or a single string possibly
320
+ # delimited by path separators.
307
321
  # @param block [Proc] Defines the subtool.
308
322
  # @return [self]
309
323
  #
310
- def tool(words, if_defined: :combine, &block)
324
+ def tool(words, if_defined: :combine, delegate_to: nil, &block)
311
325
  subtool_words = @__words
312
326
  next_remaining = @__remaining_words
313
327
  Array(words).each do |word|
@@ -326,17 +340,19 @@ module Toys
326
340
  end
327
341
  subtool_class = subtool.tool_class
328
342
  DSL::Tool.prepare(subtool_class, next_remaining, source_info) do
329
- subtool_class.class_eval(&block)
343
+ subtool_class.delegate_to(delegate_to) if delegate_to
344
+ subtool_class.class_eval(&block) if block
330
345
  end
331
346
  self
332
347
  end
333
348
  alias name tool
334
349
 
335
350
  ##
336
- # Create an alias in the current namespace.
351
+ # Create an alias, representing an "alternate name" for a tool.
337
352
  #
338
- # An alias is an alternate name for a tool. The referenced tool must be
339
- # in the same namespace.
353
+ # This is functionally equivalent to creating a subtool with the
354
+ # `delegate_to` option, except that `alias_tool` takes a _relative_ name
355
+ # for the delegate.
340
356
  #
341
357
  # ## Example
342
358
  #
@@ -351,11 +367,44 @@ module Toys
351
367
  # alias_tool "t", "test"
352
368
  #
353
369
  # @param word [String] The name of the alias
354
- # @param target [String] The target of the alias
370
+ # @param target [String,Array<String>] Relative path to the target of the
371
+ # alias. This path may be given as an array of strings, or a single
372
+ # string possibly delimited by path separators.
355
373
  # @return [self]
356
374
  #
357
375
  def alias_tool(word, target)
358
- @__loader.make_alias(@__words + [word.to_s], @__words + [target.to_s], @__priority)
376
+ tool(word, delegate_to: @__words + @__loader.split_path(target))
377
+ self
378
+ end
379
+
380
+ ##
381
+ # Causes the current tool to delegate to another tool. When run, it
382
+ # simply invokes the target tool with the same arguments.
383
+ #
384
+ # ## Example
385
+ #
386
+ # This example defines a tool that runs one of its subtools. Running the
387
+ # `test` tool will have the same effect (and recognize the same args) as
388
+ # the subtool `test unit`.
389
+ #
390
+ # tool "test" do
391
+ # tool "unit" do
392
+ # flag :faster
393
+ # def run
394
+ # puts "running tests..."
395
+ # end
396
+ # end
397
+ # delegate_to "test:unit"
398
+ # end
399
+ #
400
+ # @param target [String,Array<String>] The full path to the delegate
401
+ # tool. This path may be given as an array of strings, or a single
402
+ # string possibly delimited by path separators.
403
+ # @return [self]
404
+ #
405
+ def delegate_to(target)
406
+ cur_tool = DSL::Tool.current_tool(self, true)
407
+ cur_tool.delegate_to(@__loader.split_path(target))
359
408
  self
360
409
  end
361
410
 
@@ -403,7 +452,7 @@ module Toys
403
452
  # @param args [Object...] Template arguments
404
453
  # @return [self]
405
454
  #
406
- def expand(template_class, *args)
455
+ def expand(template_class, *args, **kwargs)
407
456
  cur_tool = DSL::Tool.current_tool(self, false)
408
457
  name = template_class.to_s
409
458
  if template_class.is_a?(::String)
@@ -414,7 +463,15 @@ module Toys
414
463
  if template_class.nil?
415
464
  raise ToolDefinitionError, "Template not found: #{name.inspect}"
416
465
  end
417
- template = template_class.new(*args)
466
+ # Due to a bug in Ruby < 2.7, passing an empty **kwargs splat to
467
+ # initialize will fail if there are no formal keyword args.
468
+ formals = template_class.instance_method(:initialize).parameters
469
+ template =
470
+ if kwargs.empty? && formals.all? { |(type, _name)| type != :key && type != :keyrest }
471
+ template_class.new(*args)
472
+ else
473
+ template_class.new(*args, **kwargs)
474
+ end
418
475
  yield template if block_given?
419
476
  class_exec(template, &template_class.expansion)
420
477
  self
@@ -1121,8 +1178,6 @@ module Toys
1121
1178
  # end
1122
1179
  # end
1123
1180
  #
1124
- # @return [self]
1125
- #
1126
1181
  # @overload static(key, value)
1127
1182
  # Set a single value by key.
1128
1183
  # @param key [String,Symbol] The key to use to retrieve the value from
@@ -1162,8 +1217,6 @@ module Toys
1162
1217
  # end
1163
1218
  # end
1164
1219
  #
1165
- # @return [self]
1166
- #
1167
1220
  # @overload set(key, value)
1168
1221
  # Set a single value by key.
1169
1222
  # @param key [String,Symbol] The key to use to retrieve the value from
@@ -1295,7 +1348,7 @@ module Toys
1295
1348
  def complete_tool_args(spec = nil, **options, &block)
1296
1349
  cur_tool = DSL::Tool.current_tool(self, true)
1297
1350
  return self if cur_tool.nil?
1298
- cur_tool.completion = cur_tool.scalar_completion(spec, options, &block)
1351
+ cur_tool.completion = Completion.scalarize_spec(spec, options, block)
1299
1352
  self
1300
1353
  end
1301
1354
 
@@ -1505,6 +1558,16 @@ module Toys
1505
1558
  DSL::Tool.current_tool(self, false)&.context_directory || source_info.context_directory
1506
1559
  end
1507
1560
 
1561
+ ##
1562
+ # Return the current tool object. This object can be queried to determine
1563
+ # such information as the name, but it should not be altered.
1564
+ #
1565
+ # @return [Toys::Tool]
1566
+ #
1567
+ def current_tool
1568
+ DSL::Tool.current_tool(self, false)
1569
+ end
1570
+
1508
1571
  ##
1509
1572
  # Set a custom context directory for this tool.
1510
1573
  #
@@ -1545,10 +1608,6 @@ module Toys
1545
1608
  else
1546
1609
  loader.get_tool(words, priority)
1547
1610
  end
1548
- if cur_tool.is_a?(Alias)
1549
- raise ToolDefinitionError,
1550
- "Cannot configure #{words.join(' ').inspect} because it is an alias"
1551
- end
1552
1611
  tool_class.instance_variable_set(memoize_var, cur_tool)
1553
1612
  end
1554
1613
  if cur_tool && activate
data/lib/toys/errors.rb CHANGED
@@ -87,7 +87,7 @@ module Toys
87
87
 
88
88
  class << self
89
89
  ## @private
90
- def capture_path(banner, path, opts = {})
90
+ def capture_path(banner, path, **opts)
91
91
  yield
92
92
  rescue ContextualError => e
93
93
  add_fields_if_missing(e, opts)
@@ -107,13 +107,13 @@ module Toys
107
107
  end
108
108
 
109
109
  ## @private
110
- def capture(banner, opts = {})
110
+ def capture(banner, **opts)
111
111
  yield
112
112
  rescue ContextualError => e
113
113
  add_fields_if_missing(e, opts)
114
114
  raise e
115
115
  rescue ::ScriptError, ::StandardError => e
116
- raise ContextualError.new(e, banner, opts)
116
+ raise ContextualError.new(e, banner, **opts)
117
117
  end
118
118
 
119
119
  private
data/lib/toys/flag.rb CHANGED
@@ -62,7 +62,7 @@ module Toys
62
62
  @long_desc = WrappableString.make_array(long_desc)
63
63
  @default = default
64
64
  @flag_completion = create_flag_completion(flag_completion)
65
- @value_completion = Completion.create(value_completion)
65
+ @value_completion = Completion.create(value_completion, **{})
66
66
  create_default_flag if @flag_syntax.empty?
67
67
  remove_used_flags(used_flags, report_collisions)
68
68
  canonicalize
@@ -373,14 +373,16 @@ module Toys
373
373
  end
374
374
 
375
375
  def create_flag_completion(spec)
376
- case spec
377
- when nil, :default
378
- DefaultCompletion.new(self)
379
- when ::Hash
380
- DefaultCompletion.new(self, spec)
381
- else
382
- Completion.create(spec)
383
- end
376
+ spec =
377
+ case spec
378
+ when nil, :default
379
+ {"": DefaultCompletion, flag: self}
380
+ when ::Hash
381
+ spec[:""].nil? ? spec.merge({"": DefaultCompletion, flag: self}) : spec
382
+ else
383
+ spec
384
+ end
385
+ Completion.create(spec, **{})
384
386
  end
385
387
 
386
388
  def create_default_flag
@@ -749,7 +751,7 @@ module Toys
749
751
  # @param include_long [Boolean] Whether to include long flags.
750
752
  # @param include_negative [Boolean] Whether to include `--no-*` forms.
751
753
  #
752
- def initialize(flag, include_short: true, include_long: true, include_negative: true)
754
+ def initialize(flag:, include_short: true, include_long: true, include_negative: true)
753
755
  @flag = flag
754
756
  @include_short = include_short
755
757
  @include_long = include_long
data/lib/toys/loader.rb CHANGED
@@ -134,8 +134,7 @@ module Toys
134
134
 
135
135
  ##
136
136
  # Given a list of command line arguments, find the appropriate tool to
137
- # handle the command, loading it from the configuration if necessary, and
138
- # following aliases.
137
+ # handle the command, loading it from the configuration if necessary.
139
138
  # This always returns a tool. If the specific tool path is not defined and
140
139
  # cannot be found in any configuration, it finds the nearest namespace that
141
140
  # *would* contain that tool, up to the root tool.
@@ -148,24 +147,35 @@ module Toys
148
147
  #
149
148
  def lookup(args)
150
149
  orig_prefix, args = find_orig_prefix(args)
151
- cur_prefix = orig_prefix
150
+ prefix = orig_prefix
152
151
  loop do
153
- load_for_prefix(cur_prefix)
154
- prefix = orig_prefix
155
- loop do
156
- tool = get_active_tool(prefix, [])
157
- if tool
158
- finish_definitions_in_tree(tool.full_name)
159
- return [tool, args.slice(prefix.length..-1)]
160
- end
161
- break if prefix.empty? || prefix.length <= cur_prefix.length
162
- prefix = prefix.slice(0..-2)
163
- end
164
- raise "Unexpected error" if cur_prefix.empty?
165
- cur_prefix = cur_prefix.slice(0..-2)
152
+ tool = lookup_specific(prefix)
153
+ return [tool, args.slice(prefix.length..-1)] if tool
154
+ prefix = prefix.slice(0..-2)
166
155
  end
167
156
  end
168
157
 
158
+ ##
159
+ # Given a tool name, looks up the specific tool, loading it from the
160
+ # configuration if necessary.
161
+ #
162
+ # If there is an active tool, returns it; otherwise, returns the highest
163
+ # priority tool that has been defined. If no tool has been defined with
164
+ # the given name, returns `nil`.
165
+ #
166
+ # @param words [Array<String>] The tool name
167
+ # @return [Toys::Tool] if the tool was found
168
+ # @return [nil] if no such tool exists
169
+ #
170
+ def lookup_specific(words)
171
+ words = split_path(words.first) if words.size == 1
172
+ load_for_prefix(words)
173
+ tool_data = get_tool_data(words)
174
+ tool = tool_data.active_definition || tool_data.top_definition
175
+ finish_definitions_in_tree(words) if tool
176
+ tool
177
+ end
178
+
169
179
  ##
170
180
  # Returns a list of subtools for the given path, loading from the
171
181
  # configuration if necessary.
@@ -175,8 +185,7 @@ module Toys
175
185
  # rather than just the immediate children (the default)
176
186
  # @param include_hidden [Boolean] If true, include hidden subtools,
177
187
  # e.g. names beginning with underscores.
178
- # @return [Array<Toys::Tool,Toys::Alias>] An array of subtools, which may
179
- # be tools or aliases.
188
+ # @return [Array<Toys::Tool>] An array of subtools.
180
189
  #
181
190
  def list_subtools(words, recursive: false, include_hidden: false)
182
191
  load_for_prefix(words)
@@ -214,6 +223,19 @@ module Toys
214
223
  false
215
224
  end
216
225
 
226
+ ##
227
+ # Splits the given path using the delimiters configured in this Loader.
228
+ # You may pass in either an array of strings, or a single string possibly
229
+ # delimited by path separators. Always returns an array of strings.
230
+ #
231
+ # @param str [String,Array<String>] The path to split.
232
+ # @return [Array<String>]
233
+ #
234
+ def split_path(str)
235
+ return str if str.is_a?(::Array)
236
+ @extra_delimiters ? str.split(@extra_delimiters) : [str]
237
+ end
238
+
217
239
  ##
218
240
  # Returns the active tool specified by the given words, with the given
219
241
  # priority, without doing any loading. If the given priority matches the
@@ -225,7 +247,6 @@ module Toys
225
247
  # @param priority [Integer] The priority of the request.
226
248
  #
227
249
  # @return [Toys::Tool] The tool found.
228
- # @return [Toys::Alias] The alias found.
229
250
  # @return [nil] if the given priority is insufficient.
230
251
  #
231
252
  # @private
@@ -239,53 +260,49 @@ module Toys
239
260
  end
240
261
 
241
262
  ##
242
- # Sets the given name as an alias to the given target.
243
- #
244
- # @param words [Array<String>] The alias name
245
- # @param target [Array<String>] The alias target name
246
- # @param priority [Integer] The priority of the request
263
+ # Returns true if the given tool name currently exists in the loader.
264
+ # Does not load the tool if not found.
247
265
  #
248
- # @return [Toys::Alias] The alias created
266
+ # @param words [Array<String>] The name of the tool.
267
+ # @return [Boolean]
249
268
  #
250
269
  # @private
251
270
  #
252
- def make_alias(words, target, priority)
253
- tool_data = get_tool_data(words)
254
- if tool_data.definitions.key?(priority)
255
- raise ToolDefinitionError,
256
- "Cannot make #{words.inspect} an alias because it is already defined"
257
- end
258
- alias_def = Alias.new(self, words, target, priority)
259
- tool_data.definitions[priority] = alias_def
260
- activate_tool(words, priority)
261
- alias_def
271
+ def tool_defined?(words)
272
+ @tool_data.key?(words)
262
273
  end
263
274
 
264
275
  ##
265
- # Returns true if the given tool name currently exists in the loader.
266
- # Does not load the tool if not found.
276
+ # Loads the subtree under the given prefix.
267
277
  #
268
- # @param words [Array<String>] The name of the tool.
269
- # @return [Boolean]
278
+ # @param prefix [Array<String>] The name prefix.
279
+ # @return [self]
270
280
  #
271
281
  # @private
272
282
  #
273
- def tool_defined?(words)
274
- @tool_data.key?(words)
283
+ def load_for_prefix(prefix)
284
+ cur_worklist = @worklist
285
+ @worklist = []
286
+ cur_worklist.each do |source, words, priority|
287
+ remaining_words = calc_remaining_words(prefix, words)
288
+ if source.source_proc
289
+ load_proc(source, words, remaining_words, priority)
290
+ elsif source.source_path
291
+ load_validated_path(source, words, remaining_words, priority)
292
+ end
293
+ end
294
+ self
275
295
  end
276
296
 
277
297
  ##
278
298
  # Get or create the tool definition for the given name and priority.
279
- # May return either a tool or alias definition.
299
+ #
300
+ # @return [Toys::Tool]
280
301
  #
281
302
  # @private
282
303
  #
283
304
  def get_tool(words, priority)
284
305
  parent = words.empty? ? nil : get_tool(words.slice(0..-2), priority)
285
- if parent.is_a?(Alias)
286
- raise ToolDefinitionError,
287
- "Cannot create children of #{parent.display_name.inspect} because it is an alias"
288
- end
289
306
  tool_data = get_tool_data(words)
290
307
  if tool_data.top_priority.nil? || tool_data.top_priority < priority
291
308
  tool_data.top_priority = priority
@@ -377,54 +394,44 @@ module Toys
377
394
  @tool_data[words] ||= ToolData.new({}, nil, nil)
378
395
  end
379
396
 
380
- ##
381
- # Returns the current effective tool given a name. Resolves any aliases.
382
- #
383
- # If there is an active tool, returns it; otherwise, returns the highest
384
- # priority tool that has been defined. If no tool has been defined with
385
- # the given name, returns `nil`.
386
- #
387
- def get_active_tool(words, looked_up = [])
388
- tool_data = get_tool_data(words)
389
- result = tool_data.active_definition
390
- case result
391
- when Alias
392
- resolve_alias(result, looked_up)
393
- when Tool
394
- result
395
- else
396
- tool_data.top_definition
397
+ def resolve_middleware(input)
398
+ input = Array(input).dup
399
+ middleware = input.shift
400
+ if middleware.is_a?(::String) || middleware.is_a?(::Symbol)
401
+ middleware = @middleware_lookup.lookup(middleware)
402
+ if middleware.nil?
403
+ raise ::ArgumentError, "Unknown middleware name #{input.first.inspect}"
404
+ end
397
405
  end
398
- end
399
-
400
- ##
401
- # Resolves the given alias
402
- #
403
- def resolve_alias(alias_tool, looked_up = [])
404
- words = alias_tool.target_name
405
- if looked_up.include?(words)
406
- raise ToolDefinitionError, "Circular alias references: #{looked_up.inspect}"
406
+ if middleware.is_a?(::Class)
407
+ middleware = build_middleware(middleware, input)
407
408
  end
408
- looked_up << words
409
- get_active_tool(words, looked_up)
409
+ unless input.empty?
410
+ raise ::ArgumentError, "Unrecognized middleware arguments: #{input.inspect}"
411
+ end
412
+ middleware
410
413
  end
411
414
 
412
- def resolve_middleware(input)
413
- input = Array(input)
414
- cls = input.first
415
- args = input[1..-1]
416
- if cls.is_a?(::String) || cls.is_a?(::Symbol)
417
- cls = @middleware_lookup.lookup(cls)
418
- if cls.nil?
419
- raise ::ArgumentError, "Unrecognized middleware name #{input.first.inspect}"
420
- end
415
+ def build_middleware(middleware_class, input)
416
+ args = input.first
417
+ if args.is_a?(::Array)
418
+ input.shift
419
+ else
420
+ args = []
421
+ end
422
+ kwargs = input.first
423
+ if kwargs.is_a?(::Hash)
424
+ input.shift
425
+ else
426
+ kwargs = {}
421
427
  end
422
- if cls.is_a?(::Class)
423
- cls.new(*args)
424
- elsif !args.empty?
425
- raise ::ArgumentError, "Unrecognized middleware object of class #{cls.class}"
428
+ # Due to a bug in Ruby < 2.7, passing an empty **kwargs splat to
429
+ # initialize will fail if there are no formal keyword args.
430
+ formals = middleware_class.instance_method(:initialize).parameters
431
+ if kwargs.empty? && formals.all? { |(type, _name)| type != :key && type != :keyrest }
432
+ middleware_class.new(*args)
426
433
  else
427
- cls
434
+ middleware_class.new(*args, **kwargs)
428
435
  end
429
436
  end
430
437
 
@@ -442,19 +449,6 @@ module Toys
442
449
  end
443
450
  end
444
451
 
445
- def load_for_prefix(prefix)
446
- cur_worklist = @worklist
447
- @worklist = []
448
- cur_worklist.each do |source, words, priority|
449
- remaining_words = calc_remaining_words(prefix, words)
450
- if source.source_proc
451
- load_proc(source, words, remaining_words, priority)
452
- elsif source.source_path
453
- load_validated_path(source, words, remaining_words, priority)
454
- end
455
- end
456
- end
457
-
458
452
  def load_proc(source, words, remaining_words, priority)
459
453
  if remaining_words
460
454
  tool_class = get_tool(words, priority).tool_class
@@ -548,11 +542,6 @@ module Toys
548
542
 
549
543
  def tool_hidden?(tool, next_tool)
550
544
  return true if tool.full_name.any? { |n| n.start_with?("_") }
551
- if tool.is_a?(Alias)
552
- original_tool = resolve_alias(tool)
553
- return true if original_tool.nil?
554
- return tool_hidden?(original_tool, nil)
555
- end
556
545
  !tool.runnable? && next_tool && next_tool.full_name.slice(0..-2) == tool.full_name
557
546
  end
558
547