dry-cli 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'dry/cli/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'dry-cli'
9
+ spec.version = Dry::CLI::VERSION
10
+ spec.authors = ['Luca Guidi']
11
+ spec.email = ['me@lucaguidi.com']
12
+ spec.licenses = ['MIT']
13
+
14
+ spec.summary = 'Dry CLI'
15
+ spec.description = 'Common framework to build command line interfaces with Ruby'
16
+ spec.homepage = 'http://dry-rb.org'
17
+
18
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
19
+ spec.metadata['source_code_uri'] = 'https://github.com/dry-rb/dry-cli'
20
+
21
+ spec.bindir = 'exe'
22
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
+ spec.require_paths = ['lib']
24
+
25
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
26
+ f.match(%r{^(test|spec|features)/})
27
+ end
28
+
29
+ spec.add_dependency 'concurrent-ruby', '~> 1.0'
30
+ spec.add_dependency 'hanami-utils', '~> 1.3'
31
+
32
+ spec.add_development_dependency 'bundler', '>= 1.6', '< 3'
33
+ spec.add_development_dependency 'rake', '~> 13.0'
34
+ spec.add_development_dependency 'rspec', '~> 3.7'
35
+ spec.add_development_dependency 'simplecov', '~> 0.17.1'
36
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Dry
4
+ #
5
+ # @since 0.1.0
6
+ module Dry
7
+ # General purpose Command Line Interface (CLI) framework for Ruby
8
+ #
9
+ # @since 0.1.0
10
+ class CLI
11
+ require 'dry/cli/version'
12
+ require 'dry/cli/errors'
13
+ require 'dry/cli/command'
14
+ require 'dry/cli/registry'
15
+ require 'dry/cli/parser'
16
+ require 'dry/cli/usage'
17
+ require 'dry/cli/banner'
18
+
19
+ # Check if command
20
+ #
21
+ # @param command [Object] the command to check
22
+ #
23
+ # @return [TrueClass,FalseClass] true if instance of `Dry::CLI::Command`
24
+ #
25
+ # @since 0.1.0
26
+ # @api private
27
+ def self.command?(command)
28
+ case command
29
+ when Class
30
+ command.ancestors.include?(Command)
31
+ else
32
+ command.is_a?(Command)
33
+ end
34
+ end
35
+
36
+ # Create a new instance
37
+ #
38
+ # @param registry [Dry::CLI::Registry] a registry
39
+ #
40
+ # @return [Dry::CLI] the new instance
41
+ # @since 0.1.0
42
+ def initialize(registry)
43
+ @commands = registry
44
+ end
45
+
46
+ # Invoke the CLI
47
+ #
48
+ # @param arguments [Array<string>] the command line arguments (defaults to `ARGV`)
49
+ # @param out [IO] the standard output (defaults to `$stdout`)
50
+ #
51
+ # @since 0.1.0
52
+ def call(arguments: ARGV, out: $stdout)
53
+ result = commands.get(arguments)
54
+
55
+ if result.found?
56
+ command, args = parse(result, out)
57
+
58
+ result.before_callbacks.run(command, args)
59
+ command.call(args)
60
+ result.after_callbacks.run(command, args)
61
+ else
62
+ usage(result, out)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ # @since 0.1.0
69
+ # @api private
70
+ attr_reader :commands
71
+
72
+ # Parse arguments for a command.
73
+ #
74
+ # It may exit in case of error, or in case of help.
75
+ #
76
+ # @param result [Dry::CLI::CommandRegistry::LookupResult]
77
+ # @param out [IO] sta output
78
+ #
79
+ # @return [Array<Dry:CLI::Command, Array>] returns an array where the
80
+ # first element is a command and the second one is the list of arguments
81
+ #
82
+ # @since 0.1.0
83
+ # @api private
84
+ def parse(result, out)
85
+ command = result.command
86
+ return [command, result.arguments] unless command?(command)
87
+
88
+ result = Parser.call(command, result.arguments, result.names)
89
+
90
+ if result.help?
91
+ Banner.call(command, out)
92
+ exit(0)
93
+ end
94
+
95
+ if result.error?
96
+ out.puts(result.error)
97
+ exit(1)
98
+ end
99
+
100
+ [command, result.arguments]
101
+ end
102
+
103
+ # Prints the command usage and exit.
104
+ #
105
+ # @param result [Dry::CLI::CommandRegistry::LookupResult]
106
+ # @param out [IO] sta output
107
+ #
108
+ # @since 0.1.0
109
+ # @api private
110
+ def usage(result, out)
111
+ Usage.call(result, out)
112
+ exit(1)
113
+ end
114
+
115
+ # Check if command
116
+ #
117
+ # @param command [Object] the command to check
118
+ #
119
+ # @return [TrueClass,FalseClass] true if instance of `Dry::CLI::Command`
120
+ #
121
+ # @since 0.1.0
122
+ # @api private
123
+ #
124
+ # @see .command?
125
+ def command?(command)
126
+ CLI.command?(command)
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli/program_name'
4
+
5
+ module Dry
6
+ class CLI
7
+ # Command banner
8
+ #
9
+ # @since 0.1.0
10
+ # @api private
11
+ module Banner
12
+ # Prints command banner
13
+ #
14
+ # @param command [Dry::CLI::Command] the command
15
+ # @param out [IO] standard output
16
+ #
17
+ # @since 0.1.0
18
+ # @api private
19
+ def self.call(command, out)
20
+ output = [
21
+ command_name(command),
22
+ command_name_and_arguments(command),
23
+ command_description(command),
24
+ command_arguments(command),
25
+ command_options(command),
26
+ command_examples(command)
27
+ ].compact.join("\n")
28
+
29
+ out.puts output
30
+ end
31
+
32
+ # @since 0.1.0
33
+ # @api private
34
+ def self.command_name(command)
35
+ "Command:\n #{full_command_name(command)}"
36
+ end
37
+
38
+ # @since 0.1.0
39
+ # @api private
40
+ def self.command_name_and_arguments(command)
41
+ "\nUsage:\n #{full_command_name(command)}#{arguments(command)}"
42
+ end
43
+
44
+ # @since 0.1.0
45
+ # @api private
46
+ def self.command_examples(command)
47
+ return if command.examples.empty?
48
+
49
+ "\nExamples:\n#{command.examples.map { |example| " #{full_command_name(command)} #{example}" }.join("\n")}" # rubocop:disable Metrics/LineLength
50
+ end
51
+
52
+ # @since 0.1.0
53
+ # @api private
54
+ def self.command_description(command)
55
+ return if command.description.nil?
56
+
57
+ "\nDescription:\n #{command.description}"
58
+ end
59
+
60
+ # @since 0.1.0
61
+ # @api private
62
+ def self.command_arguments(command)
63
+ return if command.arguments.empty?
64
+
65
+ "\nArguments:\n#{extended_command_arguments(command)}"
66
+ end
67
+
68
+ # @since 0.1.0
69
+ # @api private
70
+ def self.command_options(command)
71
+ "\nOptions:\n#{extended_command_options(command)}"
72
+ end
73
+
74
+ # @since 0.1.0
75
+ # @api private
76
+ def self.full_command_name(command)
77
+ ProgramName.call(command.command_name)
78
+ end
79
+
80
+ # @since 0.1.0
81
+ # @api private
82
+ def self.arguments(command)
83
+ required_arguments = command.required_arguments
84
+ optional_arguments = command.optional_arguments
85
+
86
+ required = required_arguments.map { |arg| arg.name.upcase }.join(' ') if required_arguments.any? # rubocop:disable Metrics/LineLength
87
+ optional = optional_arguments.map { |arg| "[#{arg.name.upcase}]" }.join(' ') if optional_arguments.any? # rubocop:disable Metrics/LineLength
88
+ result = [required, optional].compact
89
+
90
+ " #{result.join(' ')}" unless result.empty?
91
+ end
92
+
93
+ # @since 0.1.0
94
+ # @api private
95
+ def self.extended_command_arguments(command)
96
+ command.arguments.map do |argument|
97
+ " #{argument.name.to_s.upcase.ljust(20)}\t# #{'REQUIRED ' if argument.required?}#{argument.desc}" # rubocop:disable Metrics/LineLength
98
+ end.join("\n")
99
+ end
100
+
101
+ # @since 0.1.0
102
+ # @api private
103
+ #
104
+ # rubocop:disable Metrics/AbcSize
105
+ def self.extended_command_options(command)
106
+ result = command.options.map do |option|
107
+ name = Hanami::Utils::String.dasherize(option.name)
108
+ name = if option.boolean?
109
+ "[no-]#{name}"
110
+ else
111
+ "#{name}=VALUE"
112
+ end
113
+
114
+ name = "#{name}, #{option.aliases.map { |a| a.start_with?('--') ? "#{a}=VALUE" : "#{a} VALUE" }.join(', ')}" unless option.aliases.empty? # rubocop:disable Metrics/LineLength
115
+ name = " --#{name.ljust(30)}"
116
+ name = "#{name}\t# #{option.desc}"
117
+ name = "#{name}, default: #{option.default.inspect}" unless option.default.nil?
118
+ name
119
+ end
120
+
121
+ result << " --#{'help, -h'.ljust(30)}\t# Print this help"
122
+ result.join("\n")
123
+ end
124
+ # rubocop:enable Metrics/AbcSize
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'concurrent/array'
5
+ require 'dry/cli/option'
6
+
7
+ module Dry
8
+ class CLI
9
+ # Base class for commands
10
+ #
11
+ # @since 0.1.0
12
+ class Command
13
+ # @since 0.1.0
14
+ # @api private
15
+ def self.inherited(base)
16
+ super
17
+ base.extend ClassMethods
18
+ end
19
+
20
+ # @since 0.1.0
21
+ # @api private
22
+ module ClassMethods
23
+ # @since 0.1.0
24
+ # @api private
25
+ def self.extended(base)
26
+ super
27
+
28
+ base.class_eval do
29
+ @description = nil
30
+ @examples = Concurrent::Array.new
31
+ @arguments = Concurrent::Array.new
32
+ @options = Concurrent::Array.new
33
+ end
34
+ end
35
+
36
+ # @since 0.1.0
37
+ # @api private
38
+ attr_reader :description
39
+
40
+ # @since 0.1.0
41
+ # @api private
42
+ attr_reader :examples
43
+
44
+ # @since 0.1.0
45
+ # @api private
46
+ attr_reader :arguments
47
+
48
+ # @since 0.1.0
49
+ # @api private
50
+ attr_reader :options
51
+ end
52
+
53
+ # Set the description of the command
54
+ #
55
+ # @param description [String] the description
56
+ #
57
+ # @since 0.1.0
58
+ #
59
+ # @example
60
+ # require "dry/cli"
61
+ #
62
+ # class Echo < Dry::CLI::Command
63
+ # desc "Prints given input"
64
+ #
65
+ # def call(*)
66
+ # # ...
67
+ # end
68
+ # end
69
+ def self.desc(description)
70
+ @description = description
71
+ end
72
+
73
+ # Describe the usage of the command
74
+ #
75
+ # @param examples [Array<String>] one or more examples
76
+ #
77
+ # @since 0.1.0
78
+ #
79
+ # @example
80
+ # require "dry/cli"
81
+ #
82
+ # class Server < Dry::CLI::Command
83
+ # example [
84
+ # " # Basic usage (it uses the bundled server engine)",
85
+ # "--server=webrick # Force `webrick` server engine",
86
+ # "--host=0.0.0.0 # Bind to a host",
87
+ # "--port=2306 # Bind to a port",
88
+ # "--no-code-reloading # Disable code reloading"
89
+ # ]
90
+ #
91
+ # def call(*)
92
+ # # ...
93
+ # end
94
+ # end
95
+ #
96
+ # # $ foo server --help
97
+ # # # ...
98
+ # #
99
+ # # Examples:
100
+ # # foo server # Basic usage (it uses the bundled server engine)
101
+ # # foo server --server=webrick # Force `webrick` server engine
102
+ # # foo server --host=0.0.0.0 # Bind to a host
103
+ # # foo server --port=2306 # Bind to a port
104
+ # # foo server --no-code-reloading # Disable code reloading
105
+ def self.example(*examples)
106
+ @examples += examples.flatten
107
+ end
108
+
109
+ # Specify an argument
110
+ #
111
+ # @param name [Symbol] the argument name
112
+ # @param options [Hash] a set of options
113
+ #
114
+ # @since 0.1.0
115
+ #
116
+ # @example Optional argument
117
+ # require "dry/cli"
118
+ #
119
+ # class Hello < Dry::CLI::Command
120
+ # argument :name
121
+ #
122
+ # def call(name: nil, **)
123
+ # if name.nil?
124
+ # puts "Hello, stranger"
125
+ # else
126
+ # puts "Hello, #{name}"
127
+ # end
128
+ # end
129
+ # end
130
+ #
131
+ # # $ foo hello
132
+ # # Hello, stranger
133
+ #
134
+ # # $ foo hello Luca
135
+ # # Hello, Luca
136
+ #
137
+ # @example Required argument
138
+ # require "dry/cli"
139
+ #
140
+ # class Hello < Dry::CLI::Command
141
+ # argument :name, required: true
142
+ #
143
+ # def call(name:, **)
144
+ # puts "Hello, #{name}"
145
+ # end
146
+ # end
147
+ #
148
+ # # $ foo hello Luca
149
+ # # Hello, Luca
150
+ #
151
+ # # $ foo hello
152
+ # # ERROR: "foo hello" was called with no arguments
153
+ # # Usage: "foo hello NAME"
154
+ #
155
+ # @example Multiple arguments
156
+ # require "dry/cli"
157
+ #
158
+ # module Generate
159
+ # class Action < Dry::CLI::Command
160
+ # argument :app, required: true
161
+ # argument :action, required: true
162
+ #
163
+ # def call(app:, action:, **)
164
+ # puts "Generating action: #{action} for app: #{app}"
165
+ # end
166
+ # end
167
+ # end
168
+ #
169
+ # # $ foo generate action web home
170
+ # # Generating action: home for app: web
171
+ #
172
+ # # $ foo generate action
173
+ # # ERROR: "foo generate action" was called with no arguments
174
+ # # Usage: "foo generate action APP ACTION"
175
+ #
176
+ # @example Description
177
+ # require "dry/cli"
178
+ #
179
+ # class Hello < Dry::CLI::Command
180
+ # argument :name, desc: "The name of the person to greet"
181
+ #
182
+ # def call(name: nil, **)
183
+ # # ...
184
+ # end
185
+ # end
186
+ #
187
+ # # $ foo hello --help
188
+ # # Command:
189
+ # # foo hello
190
+ # #
191
+ # # Usage:
192
+ # # foo hello [NAME]
193
+ # #
194
+ # # Arguments:
195
+ # # NAME # The name of the person to greet
196
+ # #
197
+ # # Options:
198
+ # # --help, -h # Print this help
199
+ def self.argument(name, options = {})
200
+ @arguments << Argument.new(name, options)
201
+ end
202
+
203
+ # Command line option (aka optional argument)
204
+ #
205
+ # @param name [Symbol] the param name
206
+ # @param options [Hash] a set of options
207
+ #
208
+ # @since 0.1.0
209
+ #
210
+ # @example Basic usage
211
+ # require "dry/cli"
212
+ #
213
+ # class Console < Dry::CLI::Command
214
+ # param :engine
215
+ #
216
+ # def call(engine: nil, **)
217
+ # puts "starting console (engine: #{engine || :irb})"
218
+ # end
219
+ # end
220
+ #
221
+ # # $ foo console
222
+ # # starting console (engine: irb)
223
+ #
224
+ # # $ foo console --engine=pry
225
+ # # starting console (engine: pry)
226
+ #
227
+ # @example List values
228
+ # require "dry/cli"
229
+ #
230
+ # class Console < Dry::CLI::Command
231
+ # param :engine, values: %w(irb pry ripl)
232
+ #
233
+ # def call(engine: nil, **)
234
+ # puts "starting console (engine: #{engine || :irb})"
235
+ # end
236
+ # end
237
+ #
238
+ # # $ foo console
239
+ # # starting console (engine: irb)
240
+ #
241
+ # # $ foo console --engine=pry
242
+ # # starting console (engine: pry)
243
+ #
244
+ # # $ foo console --engine=foo
245
+ # # Error: Invalid param provided
246
+ #
247
+ # @example Description
248
+ # require "dry/cli"
249
+ #
250
+ # class Console < Dry::CLI::Command
251
+ # param :engine, desc: "Force a console engine"
252
+ #
253
+ # def call(engine: nil, **)
254
+ # # ...
255
+ # end
256
+ # end
257
+ #
258
+ # # $ foo console --help
259
+ # # # ...
260
+ # #
261
+ # # Options:
262
+ # # --engine=VALUE # Force a console engine: (irb/pry/ripl)
263
+ # # --help, -h # Print this help
264
+ #
265
+ # @example Boolean
266
+ # require "dry/cli"
267
+ #
268
+ # class Server < Dry::CLI::Command
269
+ # param :code_reloading, type: :boolean, default: true
270
+ #
271
+ # def call(code_reloading:, **)
272
+ # puts "staring server (code reloading: #{code_reloading})"
273
+ # end
274
+ # end
275
+ #
276
+ # # $ foo server
277
+ # # starting server (code reloading: true)
278
+ #
279
+ # # $ foo server --no-code-reloading
280
+ # # starting server (code reloading: false)
281
+ #
282
+ # # $ foo server --help
283
+ # # # ...
284
+ # #
285
+ # # Options:
286
+ # # --[no]-code-reloading
287
+ #
288
+ # @example Aliases
289
+ # require "dry/cli"
290
+ #
291
+ # class Server < Dry::CLI::Command
292
+ # param :port, aliases: ["-p"]
293
+ #
294
+ # def call(options)
295
+ # puts "staring server (port: #{options.fetch(:port, 2300)})"
296
+ # end
297
+ # end
298
+ #
299
+ # # $ foo server
300
+ # # starting server (port: 2300)
301
+ #
302
+ # # $ foo server --port=2306
303
+ # # starting server (port: 2306)
304
+ #
305
+ # # $ foo server -p 2306
306
+ # # starting server (port: 2306)
307
+ #
308
+ # # $ foo server --help
309
+ # # # ...
310
+ # #
311
+ # # Options:
312
+ # # --port=VALUE, -p VALUE
313
+ def self.option(name, options = {})
314
+ @options << Option.new(name, options)
315
+ end
316
+
317
+ # @since 0.1.0
318
+ # @api private
319
+ def self.params
320
+ (@arguments + @options).uniq
321
+ end
322
+
323
+ # @since 0.1.0
324
+ # @api private
325
+ def self.default_params
326
+ params.each_with_object({}) do |param, result|
327
+ result[param.name] = param.default unless param.default.nil?
328
+ end
329
+ end
330
+
331
+ # @since 0.1.0
332
+ # @api private
333
+ def self.required_arguments
334
+ arguments.select(&:required?)
335
+ end
336
+
337
+ # @since 0.1.0
338
+ # @api private
339
+ def self.optional_arguments
340
+ arguments.reject(&:required?)
341
+ end
342
+
343
+ extend Forwardable
344
+
345
+ delegate %i[
346
+ description
347
+ examples
348
+ arguments
349
+ options
350
+ params
351
+ default_params
352
+ required_arguments
353
+ optional_arguments
354
+ ] => 'self.class'
355
+
356
+ # @since 0.1.0
357
+ # @api private
358
+ attr_reader :command_name
359
+
360
+ # @since 0.1.0
361
+ # @api private
362
+ def initialize(command_name:, **)
363
+ @command_name = command_name
364
+ end
365
+ end
366
+ end
367
+ end