hanami-cli 0.0.0 → 0.1.0.beta1

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