cli-mastermind 1.2.5 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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