dry-cli 0.4.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.
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent/hash'
4
+ require 'hanami/utils/callbacks'
5
+
6
+ module Dry
7
+ class CLI
8
+ # Command registry
9
+ #
10
+ # @since 0.1.0
11
+ # @api private
12
+ class CommandRegistry
13
+ # @since 0.1.0
14
+ # @api private
15
+ def initialize
16
+ @root = Node.new
17
+ end
18
+
19
+ # @since 0.1.0
20
+ # @api private
21
+ def set(name, command, aliases, **options)
22
+ node = @root
23
+ command = command_for(name, command, **options)
24
+ name.split(/[[:space:]]/).each do |token|
25
+ node = node.put(node, token)
26
+ end
27
+
28
+ node.aliases!(aliases)
29
+ node.leaf!(command) unless command.nil?
30
+
31
+ nil
32
+ end
33
+
34
+ # @since 0.1.0
35
+ # @api private
36
+ #
37
+ def get(arguments)
38
+ node = @root
39
+ args = []
40
+ names = []
41
+ result = LookupResult.new(node, args, names, node.leaf?)
42
+
43
+ arguments.each_with_index do |token, i|
44
+ tmp = node.lookup(token)
45
+
46
+ if tmp.nil?
47
+ result = LookupResult.new(node, args, names, false)
48
+ break
49
+ elsif tmp.leaf?
50
+ args = arguments[i + 1..-1]
51
+ names = arguments[0..i]
52
+ node = tmp
53
+ result = LookupResult.new(node, args, names, true)
54
+ break
55
+ else
56
+ names = arguments[0..i]
57
+ node = tmp
58
+ result = LookupResult.new(node, args, names, node.leaf?)
59
+ end
60
+ end
61
+
62
+ result
63
+ end
64
+
65
+ private
66
+
67
+ # @since 0.1.0
68
+ # @api private
69
+ def command_for(name, command, **options)
70
+ if command.nil?
71
+ command
72
+ else
73
+ command.new(command_name: name, **options)
74
+ end
75
+ end
76
+
77
+ # Node of the registry
78
+ #
79
+ # @since 0.1.0
80
+ # @api private
81
+ class Node
82
+ # @since 0.1.0
83
+ # @api private
84
+ attr_reader :parent
85
+
86
+ # @since 0.1.0
87
+ # @api private
88
+ attr_reader :children
89
+
90
+ # @since 0.1.0
91
+ # @api private
92
+ attr_reader :aliases
93
+
94
+ # @since 0.1.0
95
+ # @api private
96
+ attr_reader :command
97
+
98
+ # @since 0.1.0
99
+ # @api private
100
+ attr_reader :before_callbacks
101
+
102
+ # @since 0.1.0
103
+ # @api private
104
+ attr_reader :after_callbacks
105
+
106
+ # @since 0.1.0
107
+ # @api private
108
+ def initialize(parent = nil)
109
+ @parent = parent
110
+ @children = Concurrent::Hash.new
111
+ @aliases = Concurrent::Hash.new
112
+ @command = nil
113
+
114
+ @before_callbacks = Hanami::Utils::Callbacks::Chain.new
115
+ @after_callbacks = Hanami::Utils::Callbacks::Chain.new
116
+ end
117
+
118
+ # @since 0.1.0
119
+ # @api private
120
+ def put(parent, key)
121
+ children[key] ||= self.class.new(parent)
122
+ end
123
+
124
+ # @since 0.1.0
125
+ # @api private
126
+ def lookup(token)
127
+ children[token] || aliases[token]
128
+ end
129
+
130
+ # @since 0.1.0
131
+ # @api private
132
+ def leaf!(command)
133
+ @command = command
134
+ end
135
+
136
+ # @since 0.1.0
137
+ # @api private
138
+ def alias!(key, child)
139
+ @aliases[key] = child
140
+ end
141
+
142
+ # @since 0.1.0
143
+ # @api private
144
+ def aliases!(aliases)
145
+ aliases.each do |a|
146
+ parent.alias!(a, self)
147
+ end
148
+ end
149
+
150
+ # @since 0.1.0
151
+ # @api private
152
+ def leaf?
153
+ !command.nil?
154
+ end
155
+ end
156
+
157
+ # Result of a registry lookup
158
+ #
159
+ # @since 0.1.0
160
+ # @api private
161
+ class LookupResult
162
+ # @since 0.1.0
163
+ # @api private
164
+ attr_reader :names
165
+
166
+ # @since 0.1.0
167
+ # @api private
168
+ attr_reader :arguments
169
+
170
+ # @since 0.1.0
171
+ # @api private
172
+ def initialize(node, arguments, names, found)
173
+ @node = node
174
+ @arguments = arguments
175
+ @names = names
176
+ @found = found
177
+ end
178
+
179
+ # @since 0.1.0
180
+ # @api private
181
+ def found?
182
+ @found
183
+ end
184
+
185
+ # @since 0.1.0
186
+ # @api private
187
+ def children
188
+ @node.children
189
+ end
190
+
191
+ # @since 0.1.0
192
+ # @api private
193
+ def command
194
+ @node.command
195
+ end
196
+
197
+ # @since 0.2.0
198
+ # @api private
199
+ def before_callbacks
200
+ @node.before_callbacks
201
+ end
202
+
203
+ # @since 0.2.0
204
+ # @api private
205
+ def after_callbacks
206
+ @node.after_callbacks
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hanami/utils/deprecation'
4
+
5
+ module Dry
6
+ # General purpose Command Line Interface (CLI) framework for Ruby
7
+ #
8
+ # @since 0.1.0
9
+ class CLI
10
+ # @since 0.2.0
11
+ class Error < StandardError
12
+ end
13
+
14
+ # @since 0.2.1
15
+ class UnknownCommandError < Error
16
+ # @since 0.2.1
17
+ # @api private
18
+ def initialize(command_name)
19
+ super("unknown command: `#{command_name}'")
20
+ end
21
+ end
22
+
23
+ # @since 0.2.0
24
+ class InvalidCallbackError < Error
25
+ # @since 0.2.0
26
+ # @api private
27
+ def initialize(callback)
28
+ message = case callback
29
+ when Class
30
+ "expected `#{callback.inspect}' to respond to `#initialize' with arity 0"
31
+ else
32
+ "expected `#{callback.inspect}' to respond to `#call'"
33
+ end
34
+
35
+ super(message)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hanami/utils/string'
4
+
5
+ module Dry
6
+ class CLI
7
+ # Command line option
8
+ #
9
+ # @since 0.1.0
10
+ # @api private
11
+ class Option
12
+ # @since 0.1.0
13
+ # @api private
14
+ attr_reader :name
15
+
16
+ # @since 0.1.0
17
+ # @api private
18
+ attr_reader :options
19
+
20
+ # @since 0.1.0
21
+ # @api private
22
+ def initialize(name, options = {})
23
+ @name = name
24
+ @options = options
25
+ end
26
+
27
+ # @since 0.1.0
28
+ # @api private
29
+ def aliases
30
+ options[:aliases] || []
31
+ end
32
+
33
+ # @since 0.1.0
34
+ # @api private
35
+ def desc
36
+ desc = options[:desc]
37
+ values ? "#{desc}: (#{values.join('/')})" : desc
38
+ end
39
+
40
+ # @since 0.1.0
41
+ # @api private
42
+ def required?
43
+ options[:required]
44
+ end
45
+
46
+ # @since 0.1.0
47
+ # @api private
48
+ def type
49
+ options[:type]
50
+ end
51
+
52
+ # @since 0.1.0
53
+ # @api private
54
+ def values
55
+ options[:values]
56
+ end
57
+
58
+ # @since 0.1.0
59
+ # @api private
60
+ def boolean?
61
+ type == :boolean
62
+ end
63
+
64
+ # @since 0.3.0
65
+ # @api private
66
+ def array?
67
+ type == :array
68
+ end
69
+
70
+ # @since 0.1.0
71
+ # @api private
72
+ def default
73
+ options[:default]
74
+ end
75
+
76
+ # @since 0.1.0
77
+ # @api private
78
+ def description_name
79
+ options[:label] || name.upcase
80
+ end
81
+
82
+ # @since 0.1.0
83
+ # @api private
84
+ def argument?
85
+ false
86
+ end
87
+
88
+ # @since 0.1.0
89
+ # @api private
90
+ #
91
+ # rubocop:disable Metrics/AbcSize
92
+ def parser_options
93
+ dasherized_name = Hanami::Utils::String.dasherize(name)
94
+ parser_options = []
95
+
96
+ if boolean?
97
+ parser_options << "--[no-]#{dasherized_name}"
98
+ else
99
+ parser_options << "--#{dasherized_name}=#{name}"
100
+ parser_options << "--#{dasherized_name} #{name}"
101
+ end
102
+
103
+ parser_options << Array if array?
104
+ parser_options << values if values
105
+ parser_options.unshift(alias_name) unless alias_name.nil?
106
+ parser_options << desc if desc
107
+ parser_options
108
+ end
109
+ # rubocop:enable Metrics/AbcSize
110
+
111
+ private
112
+
113
+ # @since 0.1.0
114
+ # @api private
115
+ def alias_name
116
+ aliases.join(' ') if aliases.any?
117
+ end
118
+ end
119
+
120
+ # Command line argument
121
+ #
122
+ # @since 0.1.0
123
+ # @api private
124
+ class Argument < Option
125
+ # @since 0.1.0
126
+ # @api private
127
+ def argument?
128
+ true
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'dry/cli/program_name'
5
+
6
+ module Dry
7
+ class CLI
8
+ # Parse command line arguments and options
9
+ #
10
+ # @since 0.1.0
11
+ # @api private
12
+ module Parser
13
+ # @since 0.1.0
14
+ # @api private
15
+ #
16
+ def self.call(command, arguments, names)
17
+ original_arguments = arguments.dup
18
+ parsed_options = {}
19
+
20
+ OptionParser.new do |opts|
21
+ command.options.each do |option|
22
+ opts.on(*option.parser_options) do |value|
23
+ parsed_options[option.name.to_sym] = value
24
+ end
25
+ end
26
+
27
+ opts.on_tail('-h', '--help') do
28
+ return Result.help
29
+ end
30
+ end.parse!(arguments)
31
+
32
+ parsed_options = command.default_params.merge(parsed_options)
33
+ parse_required_params(command, arguments, names, parsed_options)
34
+ rescue ::OptionParser::ParseError
35
+ Result.failure("Error: \"#{command.command_name}\" was called with arguments \"#{original_arguments.join(' ')}\"") # rubocop:disable Metrics/LineLength
36
+ end
37
+
38
+ # @since 0.1.0
39
+ # @api private
40
+ def self.full_command_name(names)
41
+ ProgramName.call(names)
42
+ end
43
+
44
+ # @since 0.1.0
45
+ # @api private
46
+ #
47
+ # rubocop:disable Metrics/AbcSize
48
+ def self.parse_required_params(command, arguments, names, parsed_options)
49
+ parsed_params = match_arguments(command.arguments, arguments)
50
+ parsed_required_params = match_arguments(command.required_arguments, arguments)
51
+ all_required_params_satisfied = command.required_arguments.all? { |param| !parsed_required_params[param.name].nil? } # rubocop:disable Metrics/LineLength
52
+
53
+ unused_arguments = arguments.drop(command.required_arguments.length)
54
+
55
+ unless all_required_params_satisfied
56
+ parsed_required_params_values = parsed_required_params.values.compact
57
+
58
+ usage = "\nUsage: \"#{full_command_name(names)} #{command.required_arguments.map(&:description_name).join(' ')}\"" # rubocop:disable Metrics/LineLength
59
+
60
+ if parsed_required_params_values.empty? # rubocop:disable Style/GuardClause
61
+ return Result.failure("ERROR: \"#{full_command_name(names)}\" was called with no arguments#{usage}") # rubocop:disable Metrics/LineLength
62
+ else
63
+ return Result.failure("ERROR: \"#{full_command_name(names)}\" was called with arguments #{parsed_required_params_values}#{usage}") # rubocop:disable Metrics/LineLength
64
+ end
65
+ end
66
+
67
+ parsed_params.reject! { |_key, value| value.nil? }
68
+ parsed_options = parsed_options.merge(parsed_params)
69
+ parsed_options = parsed_options.merge(args: unused_arguments) if unused_arguments.any?
70
+ Result.success(parsed_options)
71
+ end
72
+ # rubocop:enable Metrics/AbcSize
73
+
74
+ def self.match_arguments(command_arguments, arguments)
75
+ result = {}
76
+
77
+ command_arguments.each_with_index do |cmd_arg, index|
78
+ if cmd_arg.array?
79
+ result[cmd_arg.name] = arguments[index..-1]
80
+ break
81
+ else
82
+ result[cmd_arg.name] = arguments.at(index)
83
+ end
84
+ end
85
+
86
+ result
87
+ end
88
+
89
+ # @since 0.1.0
90
+ # @api private
91
+ class Result
92
+ # @since 0.1.0
93
+ # @api private
94
+ def self.help
95
+ new(help: true)
96
+ end
97
+
98
+ # @since 0.1.0
99
+ # @api private
100
+ def self.success(arguments = {})
101
+ new(arguments: arguments)
102
+ end
103
+
104
+ # @since 0.1.0
105
+ # @api private
106
+ def self.failure(error = 'Error: Invalid param provided')
107
+ new(error: error)
108
+ end
109
+
110
+ # @since 0.1.0
111
+ # @api private
112
+ attr_reader :arguments
113
+
114
+ # @since 0.1.0
115
+ # @api private
116
+ attr_reader :error
117
+
118
+ # @since 0.1.0
119
+ # @api private
120
+ def initialize(arguments: {}, error: nil, help: false)
121
+ @arguments = arguments
122
+ @error = error
123
+ @help = help
124
+ end
125
+
126
+ # @since 0.1.0
127
+ # @api private
128
+ def error?
129
+ !error.nil?
130
+ end
131
+
132
+ # @since 0.1.0
133
+ # @api private
134
+ def help?
135
+ @help
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end