cli-mastermind 1.2.5 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b0de9e93e04cee67c931bcb124cbcefd75c68c27a2f7d8e44eba083dd4f9fbf
4
- data.tar.gz: 559924cdd1e35d6498b6ccb23def42fdd2d0a669b9fedae234d62e5837161e95
3
+ metadata.gz: 7e6d02e18aa42a0c2c627dcdc76f2989b30a89c222bfd183a177a186f54c9953
4
+ data.tar.gz: 92541c8f669b777e89ba6c273b31f05ab3c51831653e4a2eacedd262d013ca10
5
5
  SHA512:
6
- metadata.gz: 434badb48e238d256391e1667284ab24a1d67ffb7c79a4a38625b015a84b325daec31c8626cd5163abb215d49cab82dd874346100a2b811928534ef077b5bbc9
7
- data.tar.gz: 1019f97fd8602609a1bedd3eb1ac578fe56da1c2376200fd0d04aeec366571f9b919ffc2a167618c435b3f5b20c541c241bf2484823c3a577dcad60080aee07d
6
+ metadata.gz: fbea7750593009ed092d09055dd5ee120a1ea3ee442a9576adff043d0d7214bee30f825956c99645e0eac2df177d739d505fc4efbf42a9c8ce7105f158be19ca
7
+ data.tar.gz: cc50ce47da3cc8aee137b85ae0c7bb04ca8060759a6e2a22999316ef3ee14e2a98ab5a36a41e354304158fad38c3651f642d425b912b765c525b70d689d06a7f
@@ -13,50 +13,86 @@ require 'cli/mastermind/executable_plan'
13
13
  require 'cli/mastermind/version'
14
14
 
15
15
  module CLI
16
+ # The main Mastermind module handles initial setup, user interaction, and final plan execution
16
17
  module Mastermind
17
18
  extend UserInterface
18
19
 
19
20
  class << self
20
- # Expose the configuration loaded during +execute+.
21
+ # Lazy-load configuration object
22
+ #
23
+ # @return [Configuration] the loaded configuration
21
24
  def configuration
22
- @config ||= spinner('Loading configuration') { Configuration.new @base_path }
25
+ @config ||= spinner('Loading configuration') do
26
+ Configuration.new(@base_path).tap do |config|
27
+
28
+ # Load any autoloads
29
+ if @autoloads && @autoloads.any?
30
+ @autoloads.each { |masterplan| config.load_masterplan masterplan }
31
+ end
32
+ end
33
+ end
23
34
  end
24
35
 
25
36
  # Allows utilities wrapping Mastermind to specify that only plans under a
26
37
  # particular path should be loaded.
38
+ #
39
+ # @param base_path [String] the path to the planfiles that should be loaded
40
+ # @return [Void]
27
41
  def base_path=(base_path)
28
42
  @base_path = base_path
29
43
  end
30
44
 
31
45
  # Allows utilities wrapping Mastermind to specify a top level plan without
32
46
  # having to monkey with the incomming arguments.
47
+ #
48
+ # @param base_plan [String] the top-level plan that should be automatically selected
49
+ # @return [Void]
33
50
  def base_plan=(base_plan)
34
51
  @base_plan = base_plan
35
52
  end
36
53
 
37
- # Allows utilities wrapping Mastermind to specify planfiles that should be
38
- # automatically loaded. Plans loaded this way are loaded _after_ all other
39
- # planfiles and so should only be used to set default values.
40
- def autoload_masterplan(plan_file_path)
41
- path = Pathname.new plan_file_path
42
- raise Error, "`#{plan_file_path}` is not an absolute path" unless path.absolute?
43
- raise Error, "`#{plan_file_path}` does not exist or is not a file" unless path.file?
54
+ # Convenience method for ArgParse.add_option
55
+ #
56
+ # @see ArgParse.add_option
57
+ #
58
+ # @param args arguments passed directly to OptionParser#on
59
+ # @param block [Proc] block passed as the handler for the above arguments
60
+ # @return [Void]
61
+ def add_argument(*args, &block)
62
+ ArgParse.add_option(*args, &block)
63
+ end
64
+
65
+ # Allows utilities wrapping Mastermind to specify masterplans that should be
66
+ # automatically loaded. Masterplans loaded this way are loaded _after_ all
67
+ # others and so should only be used to set default values.
68
+ #
69
+ # Adding a new autoload after configuration has been initialized will
70
+ # immediately load the new masterplan.
71
+ #
72
+ # @param masterplan_path [String] the path to the masterplan to load
73
+ # @return [Void]
74
+ def autoload_masterplan(masterplan_path)
75
+ path = Pathname.new masterplan_path
76
+ raise Error, "`#{masterplan_path}` is not an absolute path" unless path.absolute?
77
+ raise Error, "`#{masterplan_path}` does not exist or is not a file" unless path.file?
44
78
  @autoloads ||= []
45
- @autoloads << plan_file_path
79
+ @autoloads << masterplan_path
80
+
81
+ # Don't use configuration method here to avoid loading configuration early
82
+ @config.load_masterplan masterplan_path unless @config.nil?
46
83
  end
47
84
 
48
85
  # Process incoming options and take an appropriate action.
49
86
  # This is normally called by the mastermind executable.
87
+ #
88
+ # @param cli_args [Array<String>] the arguments to pass into {ArgParse}
89
+ # @return [Void]
50
90
  def execute(cli_args=ARGV)
51
91
  @arguments = ArgParse.new(cli_args)
52
92
 
53
93
  enable_ui if @arguments.display_ui?
54
94
 
55
95
  frame('Mastermind') do
56
- if @autoloads && @autoloads.any?
57
- @autoloads.each { |masterplan| configuration.load_masterplan masterplan }
58
- end
59
-
60
96
  if @arguments.dump_config?
61
97
  do_print_configuration
62
98
  exit 0
@@ -102,6 +138,10 @@ module CLI
102
138
  # While it's entirely valid to have a plan name that inlcudes a space, you
103
139
  # should avoid them if you plan to look up your plan using this method.
104
140
  #
141
+ # Plans with spaces in the name can be looked up using only the first form
142
+ # of this method.
143
+ #
144
+ # @param plan_stack [Array<String>] an array of plans to navigate to
105
145
  def [](*plan_stack)
106
146
  # Allow for a single space-separated string
107
147
  if plan_stack.size == 1 and plan_stack.first.is_a?(String)
@@ -113,12 +153,18 @@ module CLI
113
153
  end
114
154
  end
115
155
 
156
+ # Lazy-load the plans to be used by Mastermind
157
+ #
158
+ # @return [ParentPlan] the top-level parent plan which holds all loaded plans
116
159
  def plans
117
160
  @plans ||= spinner('Loading plans') { Loader.load_all configuration.plan_files }
118
161
  end
119
162
 
120
163
  private
121
164
 
165
+ # Prints the configuration object built from the loaded masterplan files.
166
+ #
167
+ # @return [Void]
122
168
  def do_print_configuration
123
169
  frame('Configuration') do
124
170
  fade_code = CLI::UI::Color.new(90, '').code
@@ -153,6 +199,9 @@ module CLI
153
199
  end
154
200
  end
155
201
 
202
+ # Filters and displays plans based on the pattern from the passed in arguements
203
+ #
204
+ # @return [Void]
156
205
  def do_filtered_plan_display
157
206
  filter_plans @arguments.pattern
158
207
 
@@ -165,6 +214,14 @@ module CLI
165
214
  end
166
215
  end
167
216
 
217
+ # Builds the string that describes a plan.
218
+ #
219
+ # Used for human-readable output of a plan's name, aliases, and description.
220
+ #
221
+ # @param plans [ParentPlan,Hash<name, Plan>] the plans to be displayed
222
+ # @param prefix [String] a prefix to print at the beginning of the output line
223
+ #
224
+ # @return [String] the display string for the given plans
168
225
  def build_display_string(plans=self.plans, prefix='')
169
226
  fade_code = CLI::UI::Color.new(90, '').code
170
227
  reset = CLI::UI::Color::RESET.code
@@ -198,6 +255,14 @@ module CLI
198
255
  display_string.gsub(/\n{3,}/, "\n\n")
199
256
  end
200
257
 
258
+ # Removes plans whose names don't match the given pattern from the tree.
259
+ #
260
+ # Modifies +plans+ in place!
261
+ #
262
+ # @param pattern [Regexp] the pattern to match against
263
+ # @param plans [ParentPlan, Hash<name, Plan>] the plans to filter
264
+ #
265
+ # @return [Void]
201
266
  def filter_plans(pattern, plans=self.plans)
202
267
  plans.keep_if do |name, plan|
203
268
  # Don't display plans without a description or children
@@ -211,6 +276,11 @@ module CLI
211
276
  end
212
277
  end
213
278
 
279
+ # Processes command line arguements to build the starting plan stack.
280
+ #
281
+ # @see ArgParse
282
+ #
283
+ # @return [Void]
214
284
  def process_plan_names
215
285
  @arguments.do_command_expansion!(configuration)
216
286
 
@@ -235,6 +305,11 @@ module CLI
235
305
  end
236
306
  end
237
307
 
308
+ # Asks the user to select a plan from the current list of plans.
309
+ #
310
+ # Repeated invokations of this command allow the user to traverse the plan tree.
311
+ #
312
+ # @return [Void]
238
313
  def do_interactive_plan_selection
239
314
  options = (@selected_plan || plans).map { |k,v| [titleize(k.to_s), v] }.to_h
240
315
 
@@ -242,14 +317,22 @@ module CLI
242
317
  @plan_stack << titleize(@selected_plan.name)
243
318
  end
244
319
 
320
+ # @return [Boolean] if the currently selected plan is executable
245
321
  def executable_plan_selected?
246
322
  not (@selected_plan.nil? or @selected_plan.has_children?)
247
323
  end
248
324
 
325
+ # Interaction can be skipped via command line flags (see {ArgParse}) or configuration
326
+ # from a masterplan (see {Configuration})
327
+ #
328
+ # @return [Boolean] if the user is sure they want to execute the given plan
249
329
  def user_is_sure?
250
330
  !@arguments.ask? or !configuration.ask? or confirm("Execute plan #{@plan_stack.join('/')}?")
251
331
  end
252
332
 
333
+ # Executes the selected plan
334
+ #
335
+ # @return [Void]
253
336
  def execute_plan!
254
337
  @selected_plan.call(@arguments.plan_arguments)
255
338
  end
@@ -1,16 +1,35 @@
1
1
  require 'optparse'
2
2
 
3
3
  module CLI::Mastermind
4
+ # Processes command line arguments and provides a more useful representation of
5
+ # the provided options.
4
6
  class ArgParse
5
- # When set, used to display available plans
7
+ # @return [Regexp] the pattern to use when filtering plans for display
6
8
  attr_reader :pattern
7
9
 
8
- # Used by mastermind to lookup plans
9
- # attr_reader :mastermind_arguments
10
-
11
- # Passed as-is into plans
10
+ # @return [Array<String>] additional command line arguements passed into the executed plan
12
11
  attr_reader :plan_arguments
13
12
 
13
+ class << self
14
+ # @see ArgParse.add_option
15
+ # @return [Array] a set of extra options added to the argument parser
16
+ attr_reader :extra_options
17
+
18
+ # Adds arbitrary options to the argument parser.
19
+ #
20
+ # Mostly useful for tools wrapping mastermind to add options that all methods
21
+ # should have access to.
22
+ #
23
+ # @param args arguments passed directly to OptionParser#on
24
+ # @param block [Proc] block passed as the handler for the above arguments
25
+ # @return [Void]
26
+ def add_option(*args, &block)
27
+ @extra_options ||= []
28
+ @extra_options << [args, block]
29
+ end
30
+ end
31
+
32
+ # @param arguments [Array<String>] the arguements to parse
14
33
  def initialize(arguments=ARGV)
15
34
  @initial_arguments = arguments
16
35
  @ask = true
@@ -21,6 +40,36 @@ module CLI::Mastermind
21
40
  parse_arguments
22
41
  end
23
42
 
43
+ # Uses configured user aliases to perform command expansion.
44
+ #
45
+ # For example, an alias defined in a masterplan like so:
46
+ #
47
+ # define_alias 'foo', 'foobar'
48
+ #
49
+ # when invoked like `mastermind foo` would operate as if the user had actually
50
+ # typed `mastermind foobar`.
51
+ #
52
+ # User aliases (defined in a masterplan) are much more powerful than planfile
53
+ # aliases (defined in a planfile). Unlike planfile aliases, user aliases
54
+ # can define entire "plan stacks" and are recursively expanded.
55
+ #
56
+ # For example, the following aliases:
57
+ #
58
+ # define_alias 'foo', 'foobar'
59
+ # define_alias 'bar', 'foo sub'
60
+ #
61
+ # invoked as `mastermind bar` would operate as if the user had actually typed
62
+ # `mastermind foobar sub`.
63
+ #
64
+ # Plan arguments can also be specified in a user alias. For example:
65
+ #
66
+ # define_alias '2-add-2', 'calculator add -- 2 2'
67
+ #
68
+ # would expand as expected with the extra arguements (`'2 2'`) being passed
69
+ # into the executed plan.
70
+ #
71
+ # @param config [Configuration] the configuration object to use when expanding user aliases
72
+ # @return [Void]
24
73
  def do_command_expansion!(config)
25
74
  @alias_arguments = []
26
75
 
@@ -35,39 +84,51 @@ module CLI::Mastermind
35
84
  end
36
85
 
37
86
  # Adds the given base plan to the beginning of the arguments array
87
+ #
88
+ # @param base_plan [String] the base plan to add to the beginning of the arguments
38
89
  def insert_base_plan!(base_plan)
39
90
  @mastermind_arguments.unshift base_plan
40
91
  nil # prevent @mastermind_arguments from leaking
41
92
  end
42
93
 
94
+ # @return [Boolean] if the user has requested plan display
43
95
  def display_plans?
44
96
  !@pattern.nil?
45
97
  end
46
98
 
99
+ # @return [Boolean] if additional plan names exist in mastermind's arguments
47
100
  def has_additional_plan_names?
48
101
  @mastermind_arguments.any?
49
102
  end
50
103
 
104
+ # Removes and returns the plan name at the beginning of the argument list.
105
+ #
106
+ # @return [String] the name of the next plan in the list of arguments
51
107
  def get_next_plan_name
52
108
  @mastermind_arguments.shift
53
109
  end
54
110
 
111
+ # @return [Boolean] if the UI is displayed
55
112
  def display_ui?
56
113
  @display_ui
57
114
  end
58
115
 
116
+ # @return [Boolean] if the user should be asked for confirmation prior to plan execution
59
117
  def ask?
60
118
  @ask
61
119
  end
62
120
 
121
+ # @return [Boolean] if the user requested their configuration be displayed
63
122
  def dump_config?
64
123
  @show_config
65
124
  end
66
125
 
126
+ # @return [Boolean] if callable attributes should be resolved prior to being displayed
67
127
  def resolve_callable_attributes?
68
128
  @call_blocks
69
129
  end
70
130
 
131
+ # @return [OptionParser] the parser to process command line arguments with
71
132
  def parser
72
133
  @parser ||= OptionParser.new do |opt|
73
134
  opt.banner = 'Usage: mastermind [--help, -h] [--plans[ PATTERN], --tasks[ PATTERN], -T [PATTERN], -P [PATTERN] [PLAN[, PLAN[, ...]]] -- [PLAN ARGUMENTS]'
@@ -93,11 +154,21 @@ module CLI::Mastermind
93
154
  @call_blocks = @show_config
94
155
  @show_config = true
95
156
  end
157
+
158
+ self.class.extra_options.each do |(arguments, block)|
159
+ opt.on(*arguments, &block)
160
+ end
96
161
  end
97
162
  end
98
163
 
99
164
  private
100
165
 
166
+ # Performs alias expansion using the provided configuration object
167
+ #
168
+ # @param config [Configuration] the configuration object used to perform expansion
169
+ # @param argument [String] the argument to be expanded
170
+ #
171
+ # @return [Array<String>, String] the expanded arguments
101
172
  def expand_argument(config, argument)
102
173
  dealiased = config.map_alias(argument)
103
174
 
@@ -121,6 +192,10 @@ module CLI::Mastermind
121
192
  dealiased
122
193
  end
123
194
 
195
+ # Splits the incoming arguments and processes those before the first `--`.
196
+ #
197
+ # Arguments after the first `--` on the command line are passed verbatim into
198
+ # the executed plan.
124
199
  def parse_arguments
125
200
  @mastermind_arguments = @initial_arguments.take_while { |arg| arg != '--' }
126
201
  @plan_arguments = @initial_arguments[(@mastermind_arguments.size + 1)..-1] || []
@@ -3,20 +3,28 @@ require 'set'
3
3
  module CLI
4
4
  module Mastermind
5
5
  ##
6
- # Main configuration object. Walks up the file tree looking for masterplans
7
- # and loading them into to build a the configuration used by the CLI.
6
+ # Main configuration object. Walks up the file tree looking for masterplan
7
+ # files and loading them to build a the configuration used by the CLI.
8
8
  #
9
- # Masterplans are loaded such that configuration specified closest to the
10
- # point of invocation override configuration from farther masterplans.
11
- # This allows you to add folder specific configuration while still falling
12
- # back to more and more general configuration options.
9
+ # These masterplan files are loaded starting from the current working directory
10
+ # and traversing up until a masterplan with a `at_project_root` directive or
11
+ # or the directory specified by a `project_root` directive is reached.
12
+ #
13
+ # Configuration options set with `configure` are latched once set to something
14
+ # non-nil. This, along with the aforementioned load order of masterplan files,
15
+ # means that masterplan files closest to the source of your invokation will
16
+ # "beat" other masterplan files.
13
17
  #
14
18
  # A global masterplan located at $HOME/.masterplan (or equivalent) is loaded
15
19
  # _last_. You can use this to specify plans you want accessible everywhere
16
20
  # or global configuration that should apply everywhere (unless overridden by
17
- # more specific masterplans).
21
+ # more proximal masterplans).
22
+ #
23
+ # Additionally, there is a directive (`see_other`) that allows for masterplan
24
+ # files outside of the lookup tree to be loaded.
18
25
  #
19
- # @see Configuration::DSL
26
+ # See {DSL} for a full list of the commands provided by Mastermind and a sample
27
+ # masterplan file.
20
28
  class Configuration
21
29
  # Filename of masterplan files
22
30
  PLANFILE = '.masterplan'
@@ -24,9 +32,15 @@ module CLI
24
32
  # Path to the top-level masterplan
25
33
  MASTER_PLAN = File.join(Dir.home, PLANFILE)
26
34
 
35
+ # The set of planfiles to load
27
36
  attr_reader :plan_files
28
37
 
29
38
  # Adds an arbitrary attribute given by +attribute+ to the configuration class
39
+ #
40
+ # @param attribute [String,Symbol] the attribute to define
41
+ #
42
+ # @!macro [attach] add_attribute
43
+ # @!attribute [rw] $1
30
44
  def self.add_attribute(attribute)
31
45
  return if self.method_defined? attribute
32
46
 
@@ -49,6 +63,7 @@ module CLI
49
63
  # masterplans, so it's important that it be set.
50
64
  add_attribute :project_root
51
65
 
66
+ # @param base_path [String,nil] plans outside of the base path will be ignored
52
67
  def initialize(base_path=nil)
53
68
  @base_path = base_path
54
69
  @loaded_masterplans = Set.new
@@ -62,7 +77,12 @@ module CLI
62
77
  load_masterplan MASTER_PLAN
63
78
  end
64
79
 
65
- # Adds a set of filenames for plans into the set of +@plan_files+
80
+ # Adds a set of filenames for plans into the set of +@plan_files+.
81
+ #
82
+ # Plans with paths outside the +@base_path+, if set, will be ignored.
83
+ #
84
+ # @param planfiles [Array<String>] new planfiles to add to the set of planfiles
85
+ # @return [Void]
66
86
  def add_plans(planfiles)
67
87
  allowed_plans = if @base_path.nil?
68
88
  planfiles
@@ -74,6 +94,9 @@ module CLI
74
94
  end
75
95
 
76
96
  # Loads a masterplan using the DSL, if it exists and hasn't been loaded already
97
+ #
98
+ # @param filename [String] the path to the masterplan to load
99
+ # @return [Void]
77
100
  def load_masterplan filename
78
101
  if File.exists? filename and !@loaded_masterplans.include? filename
79
102
  @loaded_masterplans << filename
@@ -81,26 +104,44 @@ module CLI
81
104
  end
82
105
  end
83
106
 
107
+ # Defines a user alias
108
+ #
109
+ # @param alias_from [String] the string to be replaced during expansion
110
+ # @param alias_to [String, Array<String>] the expanded argument
111
+ # @return [Void]
84
112
  def define_alias(alias_from, alias_to)
85
113
  arguments = alias_to.split(' ') if alias_to.is_a? String
86
114
 
87
115
  @aliases[alias_from] = arguments unless @aliases.has_key? alias_from
88
116
  end
89
117
 
118
+ # Maps an input string to an alias.
119
+ #
120
+ # @param input [String] the value to be replaced
121
+ # @return [String,Array<String>] the replacement alias or the input, if no replacement exists
90
122
  def map_alias(input)
91
123
  @aliases[input]
92
124
  end
93
125
 
126
+ # @return [Boolean] the user's ask_for_confirmation setting
94
127
  def ask?
95
128
  @ask_for_confirmation
96
129
  end
97
130
 
131
+ # Sets +@ask_for_confirmation+ to `false`.
132
+ #
133
+ # @return [false]
98
134
  def skip_confirmation!
99
135
  @ask_for_confirmation = false
100
136
  end
101
137
 
102
138
  private
103
139
 
140
+ # Override the default NoMethodError with a more useful MissingConfigurationError.
141
+ #
142
+ # Since the configuration object is used directly by plans for configuration information,
143
+ # accessing non-existant configuration can lead to unhelpful NoMethodErrors. This replaces
144
+ # those errors with more helpful errors.
104
145
  def method_missing(symbol, *args)
105
146
  super
106
147
  rescue NoMethodError
@@ -108,6 +149,8 @@ module CLI
108
149
  end
109
150
 
110
151
  # Walks up the file tree looking for masterplans.
152
+ #
153
+ # @return [Void]
111
154
  def lookup_and_load_masterplans
112
155
  load_masterplan File.join(Dir.pwd, PLANFILE)
113
156
 
@@ -123,6 +166,8 @@ module CLI
123
166
  # See the .masterplan file in the root of this repo for a full example of
124
167
  # the available options.
125
168
  class DSL
169
+ # @param config [Configuration] the configuration object used by the DSL
170
+ # @param filename [String] the path to the masterplan to be loaded
126
171
  def initialize(config, filename)
127
172
  @config = config
128
173
  @filename = filename
@@ -131,12 +176,17 @@ module CLI
131
176
 
132
177
  # Specifies that another masterplan should also be loaded when loading
133
178
  # this masterplan. NOTE: This _immediately_ loads the other masterplan.
179
+ #
180
+ # @param filename [String] the path to the masterplan to be loaded
134
181
  def see_also(filename)
135
182
  @config.load_masterplan(File.expand_path(filename))
136
183
  end
137
184
 
138
185
  # Specifies the root of the project.
139
186
  # +root+ must be a directory.
187
+ #
188
+ # @param root [String] the root directory of the project
189
+ # @raise [InvalidDirectoryError] if +root+ is not a directory
140
190
  def project_root(root)
141
191
  unless Dir.exist? root
142
192
  raise InvalidDirectoryError.new('Invalid project root', root)
@@ -147,12 +197,17 @@ module CLI
147
197
 
148
198
  # Syntactic sugar on top of `project_root` to specify that the current
149
199
  # masterplan resides in the root of the project.
200
+ #
201
+ # @see project_root
150
202
  def at_project_root
151
203
  project_root File.dirname(@filename)
152
204
  end
153
205
 
154
206
  # Specify that plans exist in the given +directory+.
155
- # Must be a valid directory
207
+ # Must be a valid directory.
208
+ #
209
+ # @param directory [String] path to a directory containing planfiles
210
+ # @raise [InvalidDirectoryError] if +directory+ is not a directory
156
211
  def plan_files(directory)
157
212
  unless Dir.exist? directory
158
213
  raise InvalidDirectoryError.new('Invalid plan file directory', directory)
@@ -166,11 +221,15 @@ module CLI
166
221
 
167
222
  # Syntactic sugar on top of `plan_files` to specify that plans exist in
168
223
  # a +plans/+ directory in the current directory.
224
+ #
225
+ # @see plan_files
169
226
  def has_plan_files
170
227
  plan_files File.join(File.dirname(@filename), 'plans')
171
228
  end
172
229
 
173
230
  # Specifies that a specific plan file exists at the given +filename+.
231
+ #
232
+ # @param files [Array<String>] an array of planfile paths
174
233
  def plan_file(*files)
175
234
  files = files.map { |file| File.expand_path file }
176
235
 
@@ -179,6 +238,19 @@ module CLI
179
238
 
180
239
  # Add arbitrary configuration attributes to the configuration object.
181
240
  # Use this to add plan specific configuration options.
241
+ #
242
+ # @overload configure(attribute, value=nil, &block)
243
+ # @example configure(:foo, 'bar')
244
+ # @example configure(:foo) { 'bar' }
245
+ # @param attribute [String,Symbol] the attribute to define
246
+ # @param value [] the value to assign
247
+ # @param block [#call,nil] a callable that will return the value
248
+ #
249
+ # @overload configure(attribute)
250
+ # @example configure(foo: 'bar')
251
+ # @example configure('foo' => -> { 'bar' } # not recommended, but should work
252
+ # @param attribute [Hash] a single entry hash with the key as the attribute
253
+ # name and value as the corresponding value
182
254
  def configure(attribute, value=nil, &block)
183
255
  attribute, value = attribute.first if attribute.is_a? Hash
184
256
 
@@ -189,6 +261,9 @@ module CLI
189
261
 
190
262
  # Define a user alias. User aliases are expanded as part of plan selection.
191
263
  # @see ArgParse#do_command_expansion!
264
+ #
265
+ # @param name [String] the string to be replaced
266
+ # @param arguments [String,Array<String>] the replacement
192
267
  def define_alias(name, arguments)
193
268
  @config.define_alias(name, arguments)
194
269
  end
@@ -201,6 +276,9 @@ module CLI
201
276
 
202
277
  private
203
278
 
279
+ # Used during planfile loading with a Dir.glob to load only supported planfiles
280
+ #
281
+ # @return [String] a comma separated list of supported file extensions
204
282
  def supported_extensions
205
283
  Loader.supported_extensions.join(',')
206
284
  end
@@ -1,8 +1,14 @@
1
1
  module CLI
2
2
  module Mastermind
3
+ # Executable Plan implementation. Used in Planfile Loader to generate executable
4
+ # plans from its DSL.
3
5
  class ExecutablePlan
4
6
  include Plan
5
7
 
8
+ # Implementation of {Plan#call} which calls the block this plan was created with
9
+ #
10
+ # @param (see Plan#call)
11
+ # @see Plan#call
6
12
  def call(options=nil)
7
13
  case @block.arity
8
14
  when 1, -1 then instance_exec(options, &@block)
@@ -1,13 +1,26 @@
1
1
  module CLI::Mastermind
2
+ # Loader handles loading planfiles (+not+ masterplans) and is used directly by
3
+ # Mastermind. Subclasses handle the actual parsing of plans, this class is
4
+ # primarily concerned with finding the correct loader to load a particular
5
+ # file.
6
+ #
7
+ # Loader subclasses are automatically added to the list of loaders. Thus, adding
8
+ # a new loader is as simple as subclassing and adding the appropriate methods.
2
9
  class Loader
3
10
  class << self
4
11
  attr_reader :loadable_extensions
5
12
  @@loaders = []
6
13
 
14
+ # Adds a newly subclasses loader into the set of loaders.
7
15
  def inherited(subclass)
8
16
  @@loaders << subclass
9
17
  end
10
18
 
19
+ # Finds the correct loader for a given extension
20
+ #
21
+ # @param extension [String] the extensions to search with
22
+ # @raise [UnsupportedFileTypeError] if no compatible loader is found
23
+ # @return [Loader] the loader for the given +extension+.
11
24
  def find_loader(extension)
12
25
  loader = @@loaders.find { |l| l.can_load? extension }
13
26
 
@@ -16,19 +29,32 @@ module CLI::Mastermind
16
29
  loader
17
30
  end
18
31
 
32
+ # @return [Array<String>] all loadable extensions
19
33
  def supported_extensions
20
34
  @@loaders.flat_map { |l| l.loadable_extensions }
21
35
  end
22
36
 
37
+ # @param extension [String] the extension to check
38
+ # @return [Boolean] if the +extension+ is loadable
23
39
  def can_load?(extension)
24
40
  @loadable_extensions.include? extension
25
41
  end
26
42
 
43
+ # @abstract
44
+ # Used to load a given plan.
45
+ #
46
+ # @param filename [String] the path to the planfile to be loaded
27
47
  def load(filename)
28
48
  raise NotImplementedError
29
49
  end
30
50
 
31
- # Loads a particular plan from the filesystem.
51
+ # Loads plans from the filesystem.
52
+ #
53
+ # The returned ParentPlan is intended for use by Mastermind itself.
54
+ #
55
+ # @param files [Array<String>] the list of planfiles to load
56
+ # @return [ParentPlan] a ParentPlan containing all the loaded plans
57
+ # @private
32
58
  def load_all(files)
33
59
  temp_plan = ParentPlan.new('INTERNAL PLAN HOLDER')
34
60
 
@@ -1,5 +1,7 @@
1
1
  module CLI::Mastermind
2
2
  class Loader
3
+ # Loader implementation to handle the default .plan format
4
+ # @private
3
5
  class PlanfileLoader < Loader
4
6
  @loadable_extensions = %w[ .plan ].freeze
5
7
 
@@ -15,8 +17,17 @@ module CLI::Mastermind
15
17
  class DSL
16
18
  extend Forwardable
17
19
 
20
+ # @return [Array<Plan>] the plans defined by the loaded file or block
18
21
  attr_reader :plans
19
22
 
23
+ # Loads and evaluates a local file or a given block.
24
+ #
25
+ # If given both, the block takes priority.
26
+ #
27
+ # @example DSL.new('path/to/file.plan')
28
+ # @example DSL.new { ...methods go here... }
29
+ # @param filename [String,nil] the name of the file that contains this plan
30
+ # @param block [#call,nil] a block to evaluate
20
31
  def initialize(filename=nil, &block)
21
32
  @plans = []
22
33
  @filename = filename
@@ -30,6 +41,10 @@ module CLI::Mastermind
30
41
  end
31
42
  end
32
43
 
44
+ # Describes a ParentPlan
45
+ #
46
+ # @param name [String] the name of the plan
47
+ # @param block [#call] passed to a new DSL object to define more plans
33
48
  def plot(name, &block)
34
49
  plan = ParentPlan.new name, @description, @filename
35
50
  @description = nil
@@ -38,17 +53,27 @@ module CLI::Mastermind
38
53
  end
39
54
  alias_method :namespace, :plot
40
55
 
56
+ # @param text [String] the description of the next plan
41
57
  def description(text)
42
58
  @description = text
43
59
  end
44
60
  alias_method :desc, :description
45
61
 
62
+ # Defines an executable plan
63
+ #
64
+ # @param name [String] the name of the plan
65
+ # @param plan_class [Plan] the plan class
66
+ # @param block [#call] passed into the newly created plan
67
+ # @return [void]
46
68
  def plan(name, plan_class = ExecutablePlan, &block)
47
69
  @plans << plan_class.new(name, @description, @filename, &block)
48
70
  @description = nil
49
71
  end
50
72
  alias_method :task, :plan
51
73
 
74
+ # Sets an alias on the previously created plan
75
+ #
76
+ # @param alias_to [String] the alias to add
52
77
  def set_alias(alias_to)
53
78
  @plans.last.add_alias(alias_to)
54
79
  end
@@ -1,5 +1,8 @@
1
1
  module CLI
2
2
  module Mastermind
3
+ # Plan implementation designed to hold other plans forming the intermediate
4
+ # nodes on the tree of loaded plans.
5
+ # @private
3
6
  class ParentPlan
4
7
  extend Forwardable
5
8
  include Plan
@@ -8,13 +11,21 @@ module CLI
8
11
  # Used in the interactive plan selector to display child plans
9
12
  attr_reader :children
10
13
 
14
+ # @param (see Plan)
15
+ # @see Plan
11
16
  def initialize(name, description=nil, filename=nil, &block)
12
17
  super
13
18
 
14
19
  @children = {}
15
20
  end
16
21
 
17
- # Get the child plan with the specified +name+
22
+ # Get the child plan with the specified +name+.
23
+ #
24
+ # This method also checks plan aliases, so the given +name+ can also be a
25
+ # plan's alias.
26
+ #
27
+ # @param name [String] the name of the child plan to retrieve
28
+ # @return [Plan,nil] the child plan, if it exists
18
29
  def get_child(name)
19
30
  return @children[name] if @children.has_key? name
20
31
  @children.each_value.find { |child| child.aliases.include? name }
@@ -22,23 +33,50 @@ module CLI
22
33
  alias_method :[], :get_child
23
34
  alias_method :dig, :get_child
24
35
 
36
+ # For Enumerable support
25
37
  def_delegators :@children, :each, :keep_if, :empty?
26
38
 
39
+ # Adds new children to this plan
40
+ #
41
+ # @param plans [Array<Plan>] the plans to add
42
+ # @return [Void]
27
43
  def add_children(plans)
28
44
  raise InvalidPlanError, 'Cannot add child plans to a plan with an action' unless @block.nil?
29
45
  plans.each(&method(:incorporate_plan))
30
46
  end
31
47
 
48
+ # @return [Boolean] if this plan has any children
32
49
  def has_children?
33
50
  @children.any?
34
51
  end
35
52
 
36
53
  private
37
54
 
55
+ # Adds a new plan to the children hash
56
+ #
57
+ # @param plan [Plan] the plan to add
58
+ # @see resolve_conflicts
38
59
  def incorporate_plan(plan)
39
60
  @children[plan.name] = resolve_conflicts(plan.name, plan)
40
61
  end
41
62
 
63
+ # Resolves plan name collisions.
64
+ #
65
+ # If two child plans have the same name, how they're resolved depends on
66
+ # what kind of plan they are. The following situations are convered by
67
+ # this method:
68
+ #
69
+ # 1) Both plans have children.
70
+ # * In this case, the incoming plan's children are merged into the existing
71
+ # plan and the incoming plan is discarded.
72
+ #
73
+ # 2) One or both plans have no children.
74
+ # * In this case, it's assumed that the childless plans are executable.
75
+ # A warning is printed an the incoming plan replaces the existing plan.
76
+ #
77
+ # @param key [String] the key the incoming plan will be stored under
78
+ # @param plan [Plan] the incoming plan
79
+ # @return [Plan] the plan to store
42
80
  def resolve_conflicts(key, plan)
43
81
  # If this namespace isn't taken we're good
44
82
  return plan unless @children.has_key?(key)
@@ -33,23 +33,42 @@ module CLI
33
33
  end
34
34
  end
35
35
 
36
+ # @param name [String] the name of the plan
37
+ # @param description [String] the description of the plan
38
+ # @param filename [String] the name of the file which defined this plan
39
+ # @param block [#call,nil] a callable used by ExecutablePlan
36
40
  def initialize(name, description=nil, filename=nil, &block)
37
41
  @name = name.to_s.freeze
38
42
  @description = description.freeze
39
43
  @filename = filename
44
+ # TODO: Move this to ExecutablePlan?
40
45
  @block = block
41
46
  @aliases = Set.new
42
47
  end
43
48
 
49
+ # If this plan has children.
50
+ #
51
+ # Implemented for compatibility with ParentPlan to make plan traversal easier.
52
+ #
53
+ # @return [false] Plans have no children by default
44
54
  def has_children?
45
55
  false
46
56
  end
47
57
 
58
+ # Entrypoint called by Mastermind
59
+ #
60
+ # @abstract
61
+ # @param options [Array<String>,nil] options passed from the command line
62
+ # @raise [NotImplementedError]
48
63
  def call(options=nil)
49
64
  raise NotImplementedError
50
65
  end
51
66
  alias_method :execute, :call
52
67
 
68
+ # Defines a plan alias which allows this plan to be accessed using another
69
+ # string than its name.
70
+ #
71
+ # @param alias_to [String] the alias to accept
53
72
  def add_alias(alias_to)
54
73
  @aliases.add alias_to.to_s
55
74
  end
@@ -6,14 +6,19 @@ module CLI::Mastermind::UserInterface
6
6
  CLI::UI::StdoutRouter.enable
7
7
  end
8
8
 
9
- # :private:
9
+ # @private
10
+ # @return [Boolean] if the StdoutRouter is enabled
10
11
  def ui_enabled?
11
12
  CLI::UI::StdoutRouter.enabled?
12
13
  end
13
14
 
14
15
  # Display a spinner with a +title+ while data is being loaded
15
- # @returns the value of the given block
16
+ #
16
17
  # @see https://github.com/Shopify/cli-ui#spinner-groups
18
+ #
19
+ # @param title [String] the title to display
20
+ # @param block [#call] passed to the underlying spinner implementation.
21
+ # @return the result of calling the given +block+
17
22
  def spinner(title, &block)
18
23
  return yield unless ui_enabled?
19
24
 
@@ -30,6 +35,10 @@ module CLI::Mastermind::UserInterface
30
35
  # The only difference between the two is that +AsyncSpinners+ provides a
31
36
  # mechanism for exfiltrating results by using +await+ instead of the usual
32
37
  # +add+.
38
+ #
39
+ # @see AsyncSpinners
40
+ #
41
+ # @yieldparam group [AsyncSpinners]
33
42
  def concurrently
34
43
  group = AsyncSpinners.new
35
44
 
@@ -42,6 +51,9 @@ module CLI::Mastermind::UserInterface
42
51
 
43
52
  # Uses +CLI::UI.fmt+ to format a string
44
53
  # @see https://github.com/Shopify/cli-ui#symbolglyph-formatting
54
+ #
55
+ # @param string [String] the string to format
56
+ # @return [String] the formatted string
45
57
  def stylize(string)
46
58
  CLI::UI.fmt string
47
59
  end
@@ -55,12 +67,19 @@ module CLI::Mastermind::UserInterface
55
67
 
56
68
  # Ask the user for some text.
57
69
  # @see https://github.com/Shopify/cli-ui#free-form-text-prompts
70
+ #
71
+ # @param question [String] the question to ask the user
72
+ # @param default [String] the default answer
73
+ # @return [String] the user's answer
58
74
  def ask(question, default: nil)
59
75
  CLI::UI.ask(question, default: default)
60
76
  end
61
77
 
62
78
  # Ask the user a yes/no +question+
63
79
  # @see https://github.com/Shopify/cli-ui#interactive-prompts
80
+ #
81
+ # @param question [String] the question to ask the user
82
+ # @return [Boolean] how the user answered
64
83
  def confirm(question)
65
84
  CLI::UI.confirm(question)
66
85
  end
@@ -69,13 +88,11 @@ module CLI::Mastermind::UserInterface
69
88
  # If less than 2 options would be displayed, the default value is automatically
70
89
  # returned.
71
90
  #
72
- # @param +question+ The question to ask the user
73
- # @param +options:+ Array|Hash the options to display
74
- # @param +default:+ The default value for this question. Defaults to the first
75
- # option. The default option is displayed first. Assumed to
76
- # exist within the given options.
77
- #
78
- # Any other keyword arguments given are passed down into +CLI::UI::Prompt.ask+.
91
+ # @param question [String] The question to ask the user
92
+ # @param options [Array<String>,Hash] the options to display
93
+ # @param default [String] The default value for this question. Assumed to exist
94
+ # within the given options.
95
+ # @param opts [Hash] additional options passed into +CLI::UI::Prompt.ask+.
79
96
  #
80
97
  # @see https://github.com/Shopify/cli-ui#interactive-prompts
81
98
  def select(question, options:, default: options.first, **opts)
@@ -111,14 +128,16 @@ module CLI::Mastermind::UserInterface
111
128
  end
112
129
 
113
130
  # Titleize the given +string+.
131
+ #
114
132
  # Replaces any dashes (-) or underscores (_) in the +string+ with spaces and
115
133
  # then capitalizes each word.
116
134
  #
117
- # Examples:
118
- # titleize('foo') => 'Foo'
119
- # titleize('foo bar') => 'Foo Bar'
120
- # titleize('foo-bar') => 'Foo Bar'
121
- # titleize('foo_bar') => 'Foo Bar'
135
+ # @example titleize('foo') => 'Foo'
136
+ # @example titleize('foo bar') => 'Foo Bar'
137
+ # @example titleize('foo-bar') => 'Foo Bar'
138
+ # @example titleize('foo_bar') => 'Foo Bar'
139
+ #
140
+ # @param string [String] the string to titleize.
122
141
  def titleize(string)
123
142
  string.gsub(/[-_-]/, ' ').split(' ').map(&:capitalize).join(' ')
124
143
  end
@@ -133,6 +152,11 @@ module CLI::Mastermind::UserInterface
133
152
  # Optionally, a block may be passed to modify the output of the line prior to
134
153
  # printing.
135
154
  #
155
+ # @param command [Array<String>] the command to execute
156
+ # @param kwargs [Hash] additional arguments to be passed into +IO.popen+
157
+ #
158
+ # @yieldparam line [String] a line of output to be processed
159
+ #
136
160
  # @see IO.popen
137
161
  # @see Open3.popen
138
162
  def capture_command_output(*command, **kwargs, &block)
@@ -141,6 +165,8 @@ module CLI::Mastermind::UserInterface
141
165
  IO.popen(command.flatten, **kwargs) { |io| io.each_line { |line| print block.call(line) } }
142
166
  end
143
167
 
168
+ # Implementation of CLI::UI::SpinGroup with that keeps track of the results from
169
+ # individual spinners.
144
170
  class AsyncSpinners < CLI::UI::SpinGroup
145
171
  attr_reader :results
146
172
 
@@ -149,6 +175,11 @@ module CLI::Mastermind::UserInterface
149
175
  super
150
176
  end
151
177
 
178
+ # Waits for a block to execute while displaying a spinner.
179
+ #
180
+ # @param title [String] the title to display
181
+ #
182
+ # @yieldparam spinner [CLI::UI::Spinner]
152
183
  def await(title)
153
184
  @results[title] = nil
154
185
 
@@ -7,8 +7,8 @@ module CLI
7
7
 
8
8
  module VERSION
9
9
  RELEASE = 1
10
- MAJOR = 2
11
- MINOR = 5
10
+ MAJOR = 3
11
+ MINOR = 0
12
12
  PATCH = nil
13
13
 
14
14
  STRING = [RELEASE, MAJOR, MINOR, PATCH].compact.join('.').freeze
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cli-mastermind
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.5
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Hall
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2019-12-04 00:00:00.000000000 Z
11
+ date: 2020-03-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: cli-ui
@@ -58,6 +58,20 @@ dependencies:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
60
  version: '3.0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: yard
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: 0.9.24
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 0.9.24
61
75
  description: |2
62
76
  Take over the world from your command line!
63
77
  With mastermind, you can quickly build and generate