cli-kit 4.0.0 → 5.0.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.
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