cli-kit 4.0.0 → 5.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/cla.yml +22 -0
  3. data/.github/workflows/ruby.yml +34 -2
  4. data/.gitignore +2 -0
  5. data/.rubocop.sorbet.yml +47 -0
  6. data/.rubocop.yml +16 -1
  7. data/Gemfile +10 -1
  8. data/Gemfile.lock +94 -18
  9. data/README.md +46 -3
  10. data/Rakefile +1 -0
  11. data/bin/onchange +30 -0
  12. data/bin/tapioca +29 -0
  13. data/bin/testunit +1 -0
  14. data/cli-kit.gemspec +2 -2
  15. data/dev.yml +35 -3
  16. data/examples/minimal/example.rb +3 -1
  17. data/examples/single-file/example.rb +25 -35
  18. data/gen/lib/gen/commands/help.rb +8 -10
  19. data/gen/lib/gen/commands/new.rb +23 -9
  20. data/gen/lib/gen/commands.rb +21 -9
  21. data/gen/lib/gen/entry_point.rb +12 -3
  22. data/gen/lib/gen/generator.rb +28 -7
  23. data/gen/lib/gen/help.rb +63 -0
  24. data/gen/lib/gen.rb +18 -23
  25. data/gen/template/bin/update-deps +2 -2
  26. data/gen/template/lib/__app__/commands.rb +1 -4
  27. data/gen/template/lib/__app__.rb +8 -17
  28. data/gen/template/test/example_test.rb +1 -1
  29. data/lib/cli/kit/args/definition.rb +344 -0
  30. data/lib/cli/kit/args/evaluation.rb +245 -0
  31. data/lib/cli/kit/args/parser/node.rb +132 -0
  32. data/lib/cli/kit/args/parser.rb +129 -0
  33. data/lib/cli/kit/args/tokenizer.rb +133 -0
  34. data/lib/cli/kit/args.rb +16 -0
  35. data/lib/cli/kit/base_command.rb +17 -32
  36. data/lib/cli/kit/command_help.rb +271 -0
  37. data/lib/cli/kit/command_registry.rb +69 -17
  38. data/lib/cli/kit/config.rb +25 -22
  39. data/lib/cli/kit/core_ext.rb +30 -0
  40. data/lib/cli/kit/error_handler.rb +131 -70
  41. data/lib/cli/kit/executor.rb +19 -3
  42. data/lib/cli/kit/ini.rb +31 -38
  43. data/lib/cli/kit/levenshtein.rb +12 -4
  44. data/lib/cli/kit/logger.rb +16 -2
  45. data/lib/cli/kit/opts.rb +301 -0
  46. data/lib/cli/kit/resolver.rb +8 -0
  47. data/lib/cli/kit/sorbet_runtime_stub.rb +156 -0
  48. data/lib/cli/kit/support/test_helper.rb +23 -14
  49. data/lib/cli/kit/support.rb +2 -0
  50. data/lib/cli/kit/system.rb +188 -54
  51. data/lib/cli/kit/util.rb +48 -103
  52. data/lib/cli/kit/version.rb +3 -1
  53. data/lib/cli/kit.rb +103 -7
  54. metadata +22 -10
  55. data/.github/probots.yml +0 -2
  56. data/lib/cli/kit/autocall.rb +0 -21
  57. data/lib/cli/kit/ruby_backports/enumerable.rb +0 -6
@@ -0,0 +1,129 @@
1
+ # typed: true
2
+
3
+ require 'cli/kit'
4
+
5
+ module CLI
6
+ module Kit
7
+ module Args
8
+ class Parser
9
+ extend T::Sig
10
+
11
+ autoload :Node, 'cli/kit/args/parser/node'
12
+
13
+ Error = Class.new(Args::Error)
14
+
15
+ class InvalidOptionError < Error
16
+ extend T::Sig
17
+ sig { params(option: String).void }
18
+ def initialize(option)
19
+ super("invalid option -- '#{option}'")
20
+ end
21
+ end
22
+
23
+ class OptionRequiresAnArgumentError < Error
24
+ extend T::Sig
25
+ sig { params(option: String).void }
26
+ def initialize(option)
27
+ super("option requires an argument -- '#{option}'")
28
+ end
29
+ end
30
+
31
+ sig { params(tokens: T::Array[Tokenizer::Token]).returns(T::Array[Node]) }
32
+ def parse(tokens)
33
+ nodes = T.let([], T::Array[Node])
34
+ args = T.let(tokens, T::Array[T.nilable(Tokenizer::Token)])
35
+ args << nil # to make each_cons pass (args.last, nil) on the final round.
36
+ state = :init
37
+ # TODO: test that "--height -- 3" is parsed correctly.
38
+ args.each_cons(2) do |(arg, next_arg)|
39
+ case state
40
+ when :skip
41
+ state = :init
42
+ when :init
43
+ state, val = parse_token(T.must(arg), next_arg)
44
+ nodes << val
45
+ when :unparsed
46
+ unless arg.is_a?(Tokenizer::Token::UnparsedArgument)
47
+ raise(Error, 'bug: non-unparsed argument after unparsed argument')
48
+ end
49
+
50
+ unparsed = nodes.last
51
+ unless unparsed.is_a?(Node::Unparsed)
52
+ # :nocov: not actually possible, in theory
53
+ raise(Error, 'bug: parser failed to recognize first unparsed argument')
54
+ # :nocov:
55
+ end
56
+
57
+ unparsed.value << arg.value
58
+ end
59
+ end
60
+ nodes
61
+ end
62
+
63
+ sig { params(definition: Definition).void }
64
+ def initialize(definition)
65
+ @defn = definition
66
+ end
67
+
68
+ private
69
+
70
+ sig do
71
+ params(token: Tokenizer::Token, next_token: T.nilable(Tokenizer::Token))
72
+ .returns([Symbol, Parser::Node])
73
+ end
74
+ def parse_token(token, next_token)
75
+ case token
76
+ when Tokenizer::Token::LongOptionName
77
+ case @defn.lookup_long(token.value)
78
+ when Definition::Option
79
+ [:skip, parse_option(token, next_token)]
80
+ when Definition::Flag
81
+ [:init, Node::LongFlag.new(token.value)]
82
+ else
83
+ raise(InvalidOptionError, token.value)
84
+ end
85
+ when Tokenizer::Token::ShortOptionName
86
+ case @defn.lookup_short(token.value)
87
+ when Definition::Option
88
+ [:skip, parse_option(token, next_token)]
89
+ when Definition::Flag
90
+ [:init, Node::ShortFlag.new(token.value)]
91
+ else
92
+ raise(InvalidOptionError, token.value)
93
+ end
94
+ when Tokenizer::Token::OptionValue
95
+ raise(Error, "bug: unexpected option value in argument parse sequence: #{token.value}")
96
+ when Tokenizer::Token::PositionalArgument
97
+ [:init, Node::Argument.new(token.value)]
98
+ when Tokenizer::Token::OptionValueOrPositionalArgument
99
+ [:init, Node::Argument.new(token.value)]
100
+ when Tokenizer::Token::UnparsedArgument
101
+ [:unparsed, Node::Unparsed.new([token.value])]
102
+ else
103
+ raise(Error, "bug: unexpected token type: #{token.class}")
104
+ end
105
+ end
106
+
107
+ sig { params(arg: Tokenizer::Token::OptionName, next_arg: T.nilable(Tokenizer::Token)).returns(Node) }
108
+ def parse_option(arg, next_arg)
109
+ case next_arg
110
+ when nil, Tokenizer::Token::LongOptionName,
111
+ Tokenizer::Token::ShortOptionName, Tokenizer::Token::PositionalArgument
112
+ raise(OptionRequiresAnArgumentError, arg.value)
113
+ when Tokenizer::Token::OptionValue, Tokenizer::Token::OptionValueOrPositionalArgument
114
+ case arg
115
+ when Tokenizer::Token::LongOptionName
116
+ Node::LongOption.new(arg.value, next_arg.value)
117
+ when Tokenizer::Token::ShortOptionName
118
+ Node::ShortOption.new(arg.value, next_arg.value)
119
+ else
120
+ raise(Error, "bug: unexpected token type: #{arg.class}")
121
+ end
122
+ else
123
+ raise(Error, "bug: unexpected argument type: #{next_arg.class}")
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,133 @@
1
+ # typed: true
2
+
3
+ require 'cli/kit'
4
+
5
+ module CLI
6
+ module Kit
7
+ module Args
8
+ module Tokenizer
9
+ extend T::Sig
10
+
11
+ Error = Class.new(Args::Error)
12
+
13
+ class InvalidShortOption < Error
14
+ extend T::Sig
15
+ sig { params(short_option: String).void }
16
+ def initialize(short_option)
17
+ super("invalid short option: '-#{short_option}'")
18
+ end
19
+ end
20
+
21
+ class InvalidCharInShortOption < Error
22
+ extend T::Sig
23
+ sig { params(short_option: String, char: String).void }
24
+ def initialize(short_option, char)
25
+ super("invalid character '#{char}' in short option: '-#{short_option}'")
26
+ end
27
+ end
28
+
29
+ class Token
30
+ extend T::Sig
31
+
32
+ sig { returns(String) }
33
+ attr_reader :value
34
+
35
+ sig { params(value: String).void }
36
+ def initialize(value)
37
+ @value = value
38
+ end
39
+
40
+ sig { returns(String) }
41
+ def inspect
42
+ "#<#{self.class.name} #{@value}>"
43
+ end
44
+
45
+ sig { params(other: T.untyped).returns(T::Boolean) }
46
+ def ==(other)
47
+ self.class == other.class && @value == other.value
48
+ end
49
+
50
+ OptionName = Class.new(Token)
51
+ LongOptionName = Class.new(OptionName)
52
+ ShortOptionName = Class.new(OptionName)
53
+
54
+ OptionValue = Class.new(Token)
55
+ PositionalArgument = Class.new(Token)
56
+ OptionValueOrPositionalArgument = Class.new(Token)
57
+ UnparsedArgument = Class.new(Token)
58
+ end
59
+
60
+ class << self
61
+ extend T::Sig
62
+
63
+ sig { params(raw_args: T::Array[String]).returns(T::Array[Token]) }
64
+ def tokenize(raw_args)
65
+ args = []
66
+
67
+ mode = :init
68
+
69
+ raw_args.each do |arg|
70
+ case mode
71
+ when :unparsed
72
+ args << Token::UnparsedArgument.new(arg)
73
+ when :init
74
+ case arg
75
+ when '--'
76
+ mode = :unparsed
77
+ when /\A--./
78
+ name, value = arg.split('=', 2)
79
+ args << Token::LongOptionName.new(T.must(T.must(name)[2..-1]))
80
+ if value
81
+ args << Token::OptionValue.new(value)
82
+ end
83
+ when /\A-./
84
+ args.concat(tokenize_short_option(T.must(arg[1..-1])))
85
+ else
86
+ args << if args.last.is_a?(Token::OptionName)
87
+ Token::OptionValueOrPositionalArgument.new(arg)
88
+ else
89
+ Token::PositionalArgument.new(arg)
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ args
96
+ end
97
+
98
+ sig { params(arg: String).returns(T::Array[Token]) }
99
+ def tokenize_short_option(arg)
100
+ args = []
101
+ mode = :init
102
+ number = +''
103
+ arg.each_char do |char|
104
+ case mode
105
+ when :numeric
106
+ case char
107
+ when /[0-9]/
108
+ number << char
109
+ else
110
+ raise(InvalidShortOption, arg)
111
+ end
112
+ when :init
113
+ case char
114
+ when /[a-zA-Z]/
115
+ args << Token::ShortOptionName.new(char)
116
+ when /[0-9]/
117
+ mode = :numeric
118
+ number << char
119
+ else
120
+ raise(InvalidCharInShortOption.new(arg, char))
121
+ end
122
+ end
123
+ end
124
+ if number != ''
125
+ args << Token::OptionValue.new(number)
126
+ end
127
+ args
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,16 @@
1
+ # typed: true
2
+
3
+ require 'cli/kit'
4
+
5
+ module CLI
6
+ module Kit
7
+ module Args
8
+ Error = Class.new(StandardError)
9
+
10
+ autoload :Definition, 'cli/kit/args/definition'
11
+ autoload :Parser, 'cli/kit/args/parser'
12
+ autoload :Evaluation, 'cli/kit/args/evaluation'
13
+ autoload :Tokenizer, 'cli/kit/args/tokenizer'
14
+ end
15
+ end
16
+ end
@@ -1,46 +1,31 @@
1
+ # typed: true
2
+
1
3
  require 'cli/kit'
2
4
 
3
5
  module CLI
4
6
  module Kit
5
7
  class BaseCommand
6
- def self.defined?
7
- true
8
- end
8
+ extend T::Sig
9
+ extend T::Helpers
10
+ include CLI::Kit::CommandHelp
11
+ extend CLI::Kit::CommandHelp::ClassMethods
12
+ abstract!
9
13
 
10
- def self.statsd_increment(_metric, **_kwargs)
11
- nil
12
- end
13
-
14
- def self.statsd_time(_metric, **_kwargs)
15
- yield
16
- end
14
+ class << self
15
+ extend T::Sig
17
16
 
18
- def self.call(args, command_name)
19
- cmd = new
20
- stats_tags = cmd.stats_tags(args, command_name)
21
- begin
22
- statsd_increment('cli.command.invoked', tags: stats_tags)
23
- statsd_time('cli.command.time', tags: stats_tags) do
24
- cmd.call(args, command_name)
25
- end
26
- statsd_increment('cli.command.success', tags: stats_tags)
27
- rescue Exception => e # rubocop:disable Lint/RescueException
28
- statsd_increment('cli.command.exception', tags: stats_tags + ["exception:#{e.class}"])
29
- raise e
17
+ sig { returns(T::Boolean) }
18
+ def defined?
19
+ true
30
20
  end
31
- end
32
-
33
- def stats_tags(args, command_name)
34
- tags = ["task:#{self.class}"]
35
- tags << "command:#{command_name}" if command_name
36
- tags << "subcommand:#{args.first}" if args&.first && has_subcommands?
37
- tags
38
- end
39
21
 
40
- def call(_args, _command_name)
41
- raise NotImplementedError, "#{self.class.name} must implement #{__method__}"
22
+ sig { params(args: T::Array[String], command_name: String).void }
23
+ def call(args, command_name)
24
+ new.call(args, command_name)
25
+ end
42
26
  end
43
27
 
28
+ sig { returns(T::Boolean) }
44
29
  def has_subcommands?
45
30
  false
46
31
  end
@@ -0,0 +1,271 @@
1
+ # typed: true
2
+
3
+ require 'cli/kit'
4
+
5
+ module CLI
6
+ module Kit
7
+ module CommandHelp
8
+ extend T::Sig
9
+ include Kernel # for sorbet
10
+
11
+ sig { params(args: T::Array[String], name: String).void }
12
+ def call(args, name)
13
+ begin
14
+ opts = self.class.opts_class
15
+ opts_inst = opts.new
16
+ defn = Args::Definition.new
17
+ opts_inst.define!(defn)
18
+ tokens = Args::Tokenizer.tokenize(args)
19
+ parse = Args::Parser.new(defn).parse(tokens)
20
+ evl = Args::Evaluation.new(defn, parse)
21
+ opts_inst.evaluate!(evl)
22
+ rescue Args::Evaluation::TooManyPositions, Args::Evaluation::MissingRequiredPosition => e
23
+ STDERR.puts CLI::UI.fmt("{{red:{{bold:Error: #{e.message}}}}}")
24
+ STDERR.puts
25
+ STDERR.puts self.class.build_help
26
+ raise(AbortSilent)
27
+ rescue Args::Error => e
28
+ raise(Abort, e)
29
+ end
30
+
31
+ if opts_inst.helpflag
32
+ puts self.class.build_help
33
+ else
34
+ invoke_wrapper(opts_inst, name)
35
+ end
36
+ end
37
+
38
+ # use to implement error handling
39
+ sig { params(op: T.untyped, name: String).void }
40
+ def invoke_wrapper(op, name)
41
+ invoke(op, name)
42
+ end
43
+
44
+ sig { params(op: T.untyped, name: String).void }
45
+ def invoke(op, name)
46
+ raise(NotImplementedError, '#invoke must be implemented, or #call overridden')
47
+ end
48
+
49
+ class << self
50
+ extend T::Sig
51
+
52
+ sig { params(tool_name: String).void }
53
+ attr_writer :tool_name
54
+
55
+ sig { params(max_desc_length: Integer).void }
56
+ attr_writer :max_desc_length
57
+
58
+ sig { returns(String) }
59
+ def _tool_name
60
+ unless @tool_name
61
+ raise 'You must set CLI::Kit::CommandHelp.tool_name='
62
+ end
63
+
64
+ @tool_name
65
+ end
66
+
67
+ sig { returns(Integer) }
68
+ def _max_desc_length
69
+ @max_desc_length || 80
70
+ end
71
+ end
72
+
73
+ module ClassMethods
74
+ extend T::Sig
75
+ include Kernel # for sorbet
76
+
77
+ DEFAULT_HELP_SECTIONS = [
78
+ :desc,
79
+ :long_desc,
80
+ :usage,
81
+ :examples,
82
+ :options,
83
+ ]
84
+
85
+ sig { returns(String) }
86
+ def build_help
87
+ h = (@help_sections || DEFAULT_HELP_SECTIONS).map do |section|
88
+ case section
89
+ when :desc
90
+ build_desc
91
+ when :long_desc
92
+ @long_desc
93
+ when :usage
94
+ @usage_section ||= build_usage
95
+ when :examples
96
+ @examples_section ||= build_examples
97
+ when :options
98
+ @options_section ||= build_options
99
+ else
100
+ raise "Unknown help section: #{section}"
101
+ end
102
+ end.compact.map(&:chomp).join("\n\n") + "\n"
103
+ CLI::UI.fmt(h)
104
+ end
105
+
106
+ sig { returns(String) }
107
+ def _command_name
108
+ return @command_name if @command_name
109
+
110
+ last_camel = send(:name).split('::').last
111
+ last_camel.gsub(/([a-z])([A-Z])/, '\1-\2').downcase
112
+ end
113
+
114
+ sig { returns(String) }
115
+ def _desc
116
+ @desc
117
+ end
118
+
119
+ sig { returns(String) }
120
+ def build_desc
121
+ out = +"{{command:#{CommandHelp._tool_name} #{_command_name}}}"
122
+ if @desc
123
+ out << ": #{@desc}"
124
+ end
125
+ "{{bold:#{out}}}"
126
+ end
127
+
128
+ sig { returns(T.untyped) }
129
+ def opts_class
130
+ T.unsafe(self).const_get(:Opts) # rubocop:disable Sorbet/ConstantsFromStrings
131
+ rescue NameError
132
+ Class.new(CLI::Kit::Opts)
133
+ end
134
+
135
+ sig { returns(T.nilable(String)) }
136
+ def build_options
137
+ opts = opts_class
138
+ return(nil) unless opts
139
+
140
+ methods = []
141
+ loop do
142
+ methods.concat(opts.public_instance_methods(false))
143
+ break if opts.superclass == CLI::Kit::Opts
144
+
145
+ opts = opts.superclass
146
+ end
147
+
148
+ @defn = Args::Definition.new
149
+ o = opts.new
150
+ o.define!(@defn)
151
+
152
+ return nil if @defn.options.empty? && @defn.flags.empty?
153
+
154
+ merged = T.let(@defn.options, T::Array[T.any(Args::Definition::Option, Args::Definition::Flag)])
155
+ merged += @defn.flags
156
+ merged.sort_by!(&:name)
157
+ "{{bold:Options:}}\n" + merged.map do |o|
158
+ if o.is_a?(Args::Definition::Option)
159
+ z = ' ' + [o.short&.prepend('-'), o.long&.prepend('--')].compact.join(', ') + ' VALUE'
160
+ default = if o.dynamic_default?
161
+ '(generated default)'
162
+ elsif o.default.nil?
163
+ '(no default)'
164
+ else
165
+ "(default: #{o.default.inspect})"
166
+ end
167
+ z << if o.desc
168
+ " {{italic:{{gray:# #{o.desc} #{default}}}}}"
169
+ else
170
+ " {{italic:{{gray:# #{default}}}}}"
171
+ end
172
+ else
173
+ z = ' ' + [o.short&.prepend('-'), o.long&.prepend('--')].compact.join(', ')
174
+ if o.desc
175
+ z << " {{italic:{{gray:# #{o.desc}}}}}"
176
+ end
177
+ end
178
+ z
179
+ end.join("\n")
180
+ end
181
+
182
+ sig { params(sections: T::Array[Symbol]).void }
183
+ def help_sections(sections)
184
+ @help_sections = sections
185
+ end
186
+
187
+ sig { params(command_name: String).void }
188
+ def command_name(command_name)
189
+ if @command_name
190
+ raise(ArgumentError, "Command name already set to #{@command_name}")
191
+ end
192
+
193
+ @command_name = command_name
194
+ end
195
+
196
+ sig { params(desc: String).void }
197
+ def desc(desc)
198
+ # A limit of 80 characters has been chosen to fit on standard terminal configurations. `long_desc` is
199
+ # available when descriptions don't fit nicely in that space. If you're using CLI::Kit for an application
200
+ # where you control the runtime environments and know that terminals will have more than 80 columns
201
+ # available, you can use
202
+ #
203
+ # CLI::Kit::CommandHelp.max_desc_length =
204
+ #
205
+ # to increase this limit.
206
+ if desc.size > CommandHelp._max_desc_length
207
+ raise(ArgumentError, "description must be #{CommandHelp._max_desc_length} characters or less")
208
+ end
209
+ if @desc
210
+ raise(ArgumentError, 'description already set')
211
+ end
212
+
213
+ @desc = desc
214
+ end
215
+
216
+ sig { params(long_desc: String).void }
217
+ def long_desc(long_desc)
218
+ if @long_desc
219
+ raise(ArgumentError, 'long description already set')
220
+ end
221
+
222
+ @long_desc = long_desc
223
+ end
224
+
225
+ sig { returns(String) }
226
+ def build_usage
227
+ '{{bold:Usage:}}' + case (@usage || []).size
228
+ when 0
229
+ " {{command:#{CommandHelp._tool_name} #{_command_name}}} [options]\n"
230
+ when 1
231
+ " {{command:#{CommandHelp._tool_name} #{_command_name}}} #{@usage.first}\n"
232
+ else
233
+ "\n" + @usage.map do |usage|
234
+ " {{command:#{CommandHelp._tool_name} #{_command_name}}} #{usage}\n"
235
+ end.join
236
+ end
237
+ end
238
+
239
+ sig { returns(T.nilable(String)) }
240
+ def build_examples
241
+ return nil unless @examples
242
+
243
+ cmd_prefix = " {{command:#{CommandHelp._tool_name} #{_command_name}}}"
244
+ "{{bold:Examples:}}\n" + @examples.map do |command, explanation|
245
+ cmd = "#{cmd_prefix} #{command}"
246
+ exp = "{{italic:{{gray:# #{explanation}}}}}"
247
+
248
+ width = CLI::UI::ANSI.printing_width(CLI::UI.fmt("#{cmd} #{exp}"))
249
+ if width > CLI::UI::Terminal.width
250
+ " #{exp}\n#{cmd}"
251
+ else
252
+ "#{cmd} #{exp}"
253
+ end
254
+ end.join("\n\n")
255
+ end
256
+
257
+ sig { params(usage: String).void }
258
+ def usage(usage)
259
+ @usage ||= []
260
+ @usage << usage
261
+ end
262
+
263
+ sig { params(command: String, explanation: T.nilable(String)).void }
264
+ def example(command, explanation)
265
+ @examples ||= []
266
+ @examples << [command, explanation]
267
+ end
268
+ end
269
+ end
270
+ end
271
+ end