cli-kit 3.3.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +10 -0
  3. data/.github/workflows/cla.yml +22 -0
  4. data/.github/workflows/ruby.yml +64 -0
  5. data/.gitignore +2 -0
  6. data/.rubocop.sorbet.yml +47 -0
  7. data/.rubocop.yml +22 -13
  8. data/Gemfile +13 -3
  9. data/Gemfile.lock +110 -28
  10. data/README.md +46 -3
  11. data/Rakefile +28 -1
  12. data/bin/console +3 -3
  13. data/bin/onchange +30 -0
  14. data/bin/tapioca +29 -0
  15. data/bin/test_gen +4 -1
  16. data/bin/testunit +3 -2
  17. data/cli-kit.gemspec +7 -6
  18. data/dev.yml +35 -3
  19. data/examples/minimal/example.rb +5 -3
  20. data/examples/single-file/example.rb +25 -35
  21. data/gen/lib/gen/commands/help.rb +8 -10
  22. data/gen/lib/gen/commands/new.rb +23 -9
  23. data/gen/lib/gen/commands.rb +21 -9
  24. data/gen/lib/gen/entry_point.rb +12 -3
  25. data/gen/lib/gen/generator.rb +39 -18
  26. data/gen/lib/gen/help.rb +63 -0
  27. data/gen/lib/gen.rb +18 -23
  28. data/gen/template/bin/update-deps +2 -2
  29. data/gen/template/lib/__app__/commands.rb +1 -4
  30. data/gen/template/lib/__app__.rb +8 -17
  31. data/gen/template/test/example_test.rb +1 -1
  32. data/lib/cli/kit/args/definition.rb +344 -0
  33. data/lib/cli/kit/args/evaluation.rb +245 -0
  34. data/lib/cli/kit/args/parser/node.rb +132 -0
  35. data/lib/cli/kit/args/parser.rb +129 -0
  36. data/lib/cli/kit/args/tokenizer.rb +133 -0
  37. data/lib/cli/kit/args.rb +16 -0
  38. data/lib/cli/kit/base_command.rb +17 -32
  39. data/lib/cli/kit/command_help.rb +271 -0
  40. data/lib/cli/kit/command_registry.rb +69 -17
  41. data/lib/cli/kit/config.rb +30 -25
  42. data/lib/cli/kit/core_ext.rb +30 -0
  43. data/lib/cli/kit/error_handler.rb +134 -67
  44. data/lib/cli/kit/executor.rb +39 -20
  45. data/lib/cli/kit/ini.rb +32 -39
  46. data/lib/cli/kit/levenshtein.rb +12 -4
  47. data/lib/cli/kit/logger.rb +23 -3
  48. data/lib/cli/kit/opts.rb +301 -0
  49. data/lib/cli/kit/resolver.rb +10 -2
  50. data/lib/cli/kit/sorbet_runtime_stub.rb +156 -0
  51. data/lib/cli/kit/support/test_helper.rb +31 -22
  52. data/lib/cli/kit/support.rb +2 -0
  53. data/lib/cli/kit/system.rb +217 -48
  54. data/lib/cli/kit/util.rb +52 -107
  55. data/lib/cli/kit/version.rb +3 -1
  56. data/lib/cli/kit.rb +104 -8
  57. metadata +35 -22
  58. data/.github/probots.yml +0 -2
  59. data/.travis.yml +0 -14
  60. data/lib/cli/kit/autocall.rb +0 -21
  61. data/lib/cli/kit/ruby_backports/enumerable.rb +0 -6
@@ -0,0 +1,156 @@
1
+ # typed: ignore
2
+ # frozen_string_literal: true
3
+
4
+ module T
5
+ class << self
6
+ def absurd(value); end
7
+ def all(type_a, type_b, *types); end
8
+ def any(type_a, type_b, *types); end
9
+ def attached_class; end
10
+ def class_of(klass); end
11
+ def enum(values); end
12
+ def nilable(type); end
13
+ def noreturn; end
14
+ def self_type; end
15
+ def type_alias(type = nil, &_blk); end
16
+ def type_parameter(name); end
17
+ def untyped; end
18
+
19
+ def assert_type!(value, _type, _checked: true)
20
+ value
21
+ end
22
+
23
+ def cast(value, _type, _checked: true)
24
+ value
25
+ end
26
+
27
+ def let(value, _type, _checked: true)
28
+ value
29
+ end
30
+
31
+ def must(arg, _msg = nil)
32
+ arg
33
+ end
34
+
35
+ def proc
36
+ T::Proc.new
37
+ end
38
+
39
+ def reveal_type(value)
40
+ value
41
+ end
42
+
43
+ def unsafe(value)
44
+ value
45
+ end
46
+ end
47
+
48
+ module Sig
49
+ def sig(arg0 = nil, &blk); end
50
+ end
51
+
52
+ module Helpers
53
+ def abstract!; end
54
+ def interface!; end
55
+ def final!; end
56
+ def sealed!; end
57
+ def mixes_in_class_methods(mod); end
58
+ end
59
+
60
+ module Generic
61
+ include(T::Helpers)
62
+
63
+ def type_parameters(*params); end
64
+ def type_member(variance = :invariant, fixed: nil, lower: nil, upper: BasicObject); end
65
+ def type_template(variance = :invariant, fixed: nil, lower: nil, upper: BasicObject); end
66
+
67
+ def [](*types)
68
+ self
69
+ end
70
+ end
71
+
72
+ module Array
73
+ class << self
74
+ def [](type); end
75
+ end
76
+ end
77
+
78
+ Boolean = Object.new.freeze
79
+
80
+ module Configuration
81
+ class << self
82
+ def call_validation_error_handler(signature, opts); end
83
+ def call_validation_error_handler=(value); end
84
+ def default_checked_level=(default_checked_level); end
85
+ def enable_checking_for_sigs_marked_checked_tests; end
86
+ def enable_final_checks_on_hooks; end
87
+ def enable_legacy_t_enum_migration_mode; end
88
+ def reset_final_checks_on_hooks; end
89
+ def hard_assert_handler(str, extra); end
90
+ def hard_assert_handler=(value); end
91
+ def inline_type_error_handler(error); end
92
+ def inline_type_error_handler=(value); end
93
+ def log_info_handler(str, extra); end
94
+ def log_info_handler=(value); end
95
+ def scalar_types; end
96
+ def scalar_types=(values); end
97
+ # rubocop:disable Naming/InclusiveLanguage
98
+ def sealed_violation_whitelist; end
99
+ def sealed_violation_whitelist=(sealed_violation_whitelist); end
100
+ # rubocop:enable Naming/InclusiveLanguage
101
+ def sig_builder_error_handler=(value); end
102
+ def sig_validation_error_handler(error, opts); end
103
+ def sig_validation_error_handler=(value); end
104
+ def soft_assert_handler(str, extra); end
105
+ def soft_assert_handler=(value); end
106
+ end
107
+ end
108
+
109
+ module Enumerable
110
+ class << self
111
+ def [](type); end
112
+ end
113
+ end
114
+
115
+ module Enumerator
116
+ class << self
117
+ def [](type); end
118
+ end
119
+ end
120
+
121
+ module Hash
122
+ class << self
123
+ def [](keys, values); end
124
+ end
125
+ end
126
+
127
+ class Proc
128
+ def bind(*_)
129
+ self
130
+ end
131
+
132
+ def params(*_param)
133
+ self
134
+ end
135
+
136
+ def void
137
+ self
138
+ end
139
+
140
+ def returns(_type)
141
+ self
142
+ end
143
+ end
144
+
145
+ module Range
146
+ class << self
147
+ def [](type); end
148
+ end
149
+ end
150
+
151
+ module Set
152
+ class << self
153
+ def [](type); end
154
+ end
155
+ end
156
+ end
@@ -1,3 +1,5 @@
1
+ require 'cli/kit'
2
+
1
3
  module CLI
2
4
  module Kit
3
5
  module Support
@@ -10,7 +12,9 @@ module CLI
10
12
  def assert_all_commands_run(should_raise: true)
11
13
  errors = CLI::Kit::System.error_message
12
14
  CLI::Kit::System.reset!
13
- assert false, errors if should_raise && !errors.nil?
15
+ # this is in minitest, but sorbet doesn't know that. probably we
16
+ # could structure this better.
17
+ T.unsafe(self).assert(false, errors) if should_raise && !errors.nil?
14
18
  errors
15
19
  end
16
20
 
@@ -52,30 +56,32 @@ module CLI
52
56
  module System
53
57
  class << self
54
58
  alias_method :original_system, :system
55
- def system(*a, sudo: false, env: {}, **kwargs)
56
- expected_command = expected_command(*a, sudo: sudo, env: env)
59
+ def system(cmd, *a, sudo: false, env: {}, stdin: nil, **kwargs)
60
+ a.unshift(cmd)
61
+ expected_command = expected_command(a, sudo: sudo, env: env)
57
62
 
58
63
  # In the case of an unexpected command, expected_command will be nil
59
64
  return FakeSuccess.new(false) if expected_command.nil?
60
65
 
61
66
  # Otherwise handle the command
62
67
  if expected_command[:allow]
63
- original_system(*a, sudo: sudo, env: env, **kwargs)
68
+ T.unsafe(self).original_system(*a, sudo: sudo, env: env, **kwargs)
64
69
  else
65
70
  FakeSuccess.new(expected_command[:success])
66
71
  end
67
72
  end
68
73
 
69
74
  alias_method :original_capture2, :capture2
70
- def capture2(*a, sudo: false, env: {}, **kwargs)
71
- expected_command = expected_command(*a, sudo: sudo, env: env)
75
+ def capture2(cmd, *a, sudo: false, env: {}, **kwargs)
76
+ a.unshift(cmd)
77
+ expected_command = expected_command(a, sudo: sudo, env: env)
72
78
 
73
79
  # In the case of an unexpected command, expected_command will be nil
74
80
  return [nil, FakeSuccess.new(false)] if expected_command.nil?
75
81
 
76
82
  # Otherwise handle the command
77
83
  if expected_command[:allow]
78
- original_capture2(*a, sudo: sudo, env: env, **kwargs)
84
+ T.unsafe(self).original_capture2(*a, sudo: sudo, env: env, **kwargs)
79
85
  else
80
86
  [
81
87
  expected_command[:stdout],
@@ -85,15 +91,16 @@ module CLI
85
91
  end
86
92
 
87
93
  alias_method :original_capture2e, :capture2e
88
- def capture2e(*a, sudo: false, env: {}, **kwargs)
89
- expected_command = expected_command(*a, sudo: sudo, env: env)
94
+ def capture2e(cmd, *a, sudo: false, env: {}, **kwargs)
95
+ a.unshift(cmd)
96
+ expected_command = expected_command(a, sudo: sudo, env: env)
90
97
 
91
98
  # In the case of an unexpected command, expected_command will be nil
92
99
  return [nil, FakeSuccess.new(false)] if expected_command.nil?
93
100
 
94
101
  # Otherwise handle the command
95
102
  if expected_command[:allow]
96
- original_capture2ecapture2e(*a, sudo: sudo, env: env, **kwargs)
103
+ T.unsafe(self).original_capture2e(*a, sudo: sudo, env: env, **kwargs)
97
104
  else
98
105
  [
99
106
  expected_command[:stdout],
@@ -103,15 +110,16 @@ module CLI
103
110
  end
104
111
 
105
112
  alias_method :original_capture3, :capture3
106
- def capture3(*a, sudo: false, env: {}, **kwargs)
107
- expected_command = expected_command(*a, sudo: sudo, env: env)
113
+ def capture3(cmd, *a, sudo: false, env: {}, **kwargs)
114
+ a.unshift(cmd)
115
+ expected_command = expected_command(a, sudo: sudo, env: env)
108
116
 
109
117
  # In the case of an unexpected command, expected_command will be nil
110
118
  return [nil, nil, FakeSuccess.new(false)] if expected_command.nil?
111
119
 
112
120
  # Otherwise handle the command
113
121
  if expected_command[:allow]
114
- original_capture3(*a, sudo: sudo, env: env, **kwargs)
122
+ T.unsafe(self).original_capture3(*a, sudo: sudo, env: env, **kwargs)
115
123
  else
116
124
  [
117
125
  expected_command[:stdout],
@@ -134,8 +142,8 @@ module CLI
134
142
  #
135
143
  # Note: Must set allow or success
136
144
  #
137
- def fake(*a, stdout: "", stderr: "", allow: nil, success: nil, sudo: false, env: {})
138
- raise ArgumentError, "success or allow must be set" if success.nil? && allow.nil?
145
+ def fake(*a, stdout: '', stderr: '', allow: nil, success: nil, sudo: false, env: {})
146
+ raise ArgumentError, 'success or allow must be set' if success.nil? && allow.nil?
139
147
 
140
148
  @delegate_open3 ||= {}
141
149
  @delegate_open3[a.join(' ')] = {
@@ -196,32 +204,33 @@ module CLI
196
204
 
197
205
  unless errors[:unexpected].empty?
198
206
  final_error << CLI::UI.fmt(<<~EOF)
199
- {{bold:Unexpected command invocations:}}
200
- {{command:#{errors[:unexpected].join("\n")}}}
207
+ {{bold:Unexpected command invocations:}}
208
+ {{command:#{errors[:unexpected].join("\n")}}}
201
209
  EOF
202
210
  end
203
211
 
204
212
  unless errors[:not_run].empty?
205
213
  final_error << CLI::UI.fmt(<<~EOF)
206
- {{bold:Expected commands were not run:}}
207
- {{command:#{errors[:not_run].join("\n")}}}
214
+ {{bold:Expected commands were not run:}}
215
+ {{command:#{errors[:not_run].join("\n")}}}
208
216
  EOF
209
217
  end
210
218
 
211
219
  unless errors[:other].empty?
212
220
  final_error << CLI::UI.fmt(<<~EOF)
213
- {{bold:Commands were not run as expected:}}
214
- #{errors[:other].map { |cmd, msg| "{{command:#{cmd}}}\n#{msg}" }.join("\n\n")}
221
+ {{bold:Commands were not run as expected:}}
222
+ #{errors[:other].map { |cmd, msg| "{{command:#{cmd}}}\n#{msg}" }.join("\n\n")}
215
223
  EOF
216
224
  end
217
225
 
218
226
  return nil if final_error.empty?
227
+
219
228
  "\n" + final_error.join("\n") # Initial new line for formatting reasons
220
229
  end
221
230
 
222
231
  private
223
232
 
224
- def expected_command(*a, sudo: raise, env: raise)
233
+ def expected_command(a, sudo: raise, env: raise)
225
234
  expected_cmd = @delegate_open3[a.join(' ')]
226
235
 
227
236
  if expected_cmd.nil?
@@ -1,3 +1,5 @@
1
+ # typed: true
2
+
1
3
  require 'cli/kit'
2
4
 
3
5
  module CLI
@@ -1,13 +1,16 @@
1
- require 'cli/kit'
1
+ # typed: true
2
2
 
3
+ require 'cli/kit'
3
4
  require 'open3'
4
5
  require 'English'
5
6
 
6
7
  module CLI
7
8
  module Kit
8
9
  module System
9
- SUDO_PROMPT = CLI::UI.fmt("{{info:(sudo)}} Password: ")
10
+ SUDO_PROMPT = CLI::UI.fmt('{{info:(sudo)}} Password: ')
10
11
  class << self
12
+ extend T::Sig
13
+
11
14
  # Ask for sudo access with a message explaning the need for it
12
15
  # Will make subsequent commands capable of running with sudo for a period of time
13
16
  #
@@ -17,10 +20,12 @@ module CLI
17
20
  # #### Usage
18
21
  # `ctx.sudo_reason("We need to do a thing")`
19
22
  #
23
+ sig { params(msg: String).void }
20
24
  def sudo_reason(msg)
21
25
  # See if sudo has a cached password
22
- `env SUDO_ASKPASS=/usr/bin/false sudo -A true`
26
+ %x(env SUDO_ASKPASS=/usr/bin/false sudo -A true > /dev/null 2>&1)
23
27
  return if $CHILD_STATUS.success?
28
+
24
29
  CLI::UI.with_frame_color(:blue) do
25
30
  puts(CLI::UI.fmt("{{i}} #{msg}"))
26
31
  end
@@ -43,8 +48,18 @@ module CLI
43
48
  # #### Usage
44
49
  # `out, stat = CLI::Kit::System.capture2('ls', 'a_folder')`
45
50
  #
46
- def capture2(*a, sudo: false, env: ENV, **kwargs)
47
- delegate_open3(*a, sudo: sudo, env: env, method: :capture2, **kwargs)
51
+ sig do
52
+ params(
53
+ cmd: String,
54
+ args: String,
55
+ sudo: T.any(T::Boolean, String),
56
+ env: T::Hash[String, T.nilable(String)],
57
+ kwargs: T.untyped,
58
+ )
59
+ .returns([String, Process::Status])
60
+ end
61
+ def capture2(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
62
+ delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture2)
48
63
  end
49
64
 
50
65
  # Execute a command in the user's environment
@@ -64,8 +79,18 @@ module CLI
64
79
  # #### Usage
65
80
  # `out_and_err, stat = CLI::Kit::System.capture2e('ls', 'a_folder')`
66
81
  #
67
- def capture2e(*a, sudo: false, env: ENV, **kwargs)
68
- delegate_open3(*a, sudo: sudo, env: env, method: :capture2e, **kwargs)
82
+ sig do
83
+ params(
84
+ cmd: String,
85
+ args: String,
86
+ sudo: T.any(T::Boolean, String),
87
+ env: T::Hash[String, T.nilable(String)],
88
+ kwargs: T.untyped,
89
+ )
90
+ .returns([String, Process::Status])
91
+ end
92
+ def capture2e(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
93
+ delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture2e)
69
94
  end
70
95
 
71
96
  # Execute a command in the user's environment
@@ -86,8 +111,72 @@ module CLI
86
111
  # #### Usage
87
112
  # `out, err, stat = CLI::Kit::System.capture3('ls', 'a_folder')`
88
113
  #
89
- def capture3(*a, sudo: false, env: ENV, **kwargs)
90
- delegate_open3(*a, sudo: sudo, env: env, method: :capture3, **kwargs)
114
+ sig do
115
+ params(
116
+ cmd: String,
117
+ args: String,
118
+ sudo: T.any(T::Boolean, String),
119
+ env: T::Hash[String, T.nilable(String)],
120
+ kwargs: T.untyped,
121
+ )
122
+ .returns([String, String, Process::Status])
123
+ end
124
+ def capture3(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
125
+ delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture3)
126
+ end
127
+
128
+ sig do
129
+ params(
130
+ cmd: String,
131
+ args: String,
132
+ sudo: T.any(T::Boolean, String),
133
+ env: T::Hash[String, T.nilable(String)],
134
+ kwargs: T.untyped,
135
+ block: T.nilable(
136
+ T.proc.params(stdin: IO, stdout: IO, wait_thr: Process::Waiter)
137
+ .returns([IO, IO, Process::Waiter]),
138
+ ),
139
+ )
140
+ .returns([IO, IO, Process::Waiter])
141
+ end
142
+ def popen2(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block)
143
+ delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :popen2, &block)
144
+ end
145
+
146
+ sig do
147
+ params(
148
+ cmd: String,
149
+ args: String,
150
+ sudo: T.any(T::Boolean, String),
151
+ env: T::Hash[String, T.nilable(String)],
152
+ kwargs: T.untyped,
153
+ block: T.nilable(
154
+ T.proc.params(stdin: IO, stdout: IO, wait_thr: Process::Waiter)
155
+ .returns([IO, IO, Process::Waiter]),
156
+ ),
157
+ )
158
+ .returns([IO, IO, Process::Waiter])
159
+ end
160
+ def popen2e(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block)
161
+ delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :popen2e, &block)
162
+ end
163
+
164
+ sig do
165
+ params(
166
+ cmd: String,
167
+ args: String,
168
+ sudo: T.any(T::Boolean, String),
169
+ env: T::Hash[String, T.nilable(String)],
170
+ kwargs: T.untyped,
171
+ block: T.nilable(
172
+ T.proc.params(stdin: IO, stdout: IO, stderr: IO, wait_thr: Process::Waiter)
173
+ .returns([IO, IO, IO, Process::Waiter]),
174
+ ),
175
+ )
176
+ .returns([IO, IO, IO, Process::Waiter])
177
+ end
178
+ def popen3(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block)
179
+ delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :popen3, &block)
91
180
  end
92
181
 
93
182
  # Execute a command in the user's environment
@@ -100,27 +189,50 @@ module CLI
100
189
  # - `**kwargs`: additional keyword arguments to pass to Process.spawn
101
190
  #
102
191
  # #### Returns
103
- # - `status`: boolean success status of the command execution
192
+ # - `status`: The `Process:Status` result for the command execution
104
193
  #
105
194
  # #### Usage
106
195
  # `stat = CLI::Kit::System.system('ls', 'a_folder')`
107
196
  #
108
- def system(*a, sudo: false, env: ENV, **kwargs)
109
- a = apply_sudo(*a, sudo)
197
+ sig do
198
+ params(
199
+ cmd: String,
200
+ args: String,
201
+ sudo: T.any(T::Boolean, String),
202
+ env: T::Hash[String, T.nilable(String)],
203
+ stdin: T.nilable(T.any(IO, String, Integer, Symbol)),
204
+ kwargs: T.untyped,
205
+ block: T.nilable(T.proc.params(out: String, err: String).void),
206
+ )
207
+ .returns(Process::Status)
208
+ end
209
+ def system(cmd, *args, sudo: false, env: ENV.to_h, stdin: nil, **kwargs, &block)
210
+ cmd, args = apply_sudo(cmd, args, sudo)
110
211
 
111
212
  out_r, out_w = IO.pipe
112
213
  err_r, err_w = IO.pipe
113
- in_stream = STDIN.closed? ? :close : STDIN
114
- pid = Process.spawn(env, *resolve_path(a, env), 0 => in_stream, :out => out_w, :err => err_w, **kwargs)
214
+ in_stream = if stdin
215
+ stdin
216
+ elsif STDIN.closed?
217
+ :close
218
+ else
219
+ STDIN
220
+ end
221
+ cmd, args = resolve_path(cmd, args, env)
222
+ pid = T.unsafe(Process).spawn(env, cmd, *args, 0 => in_stream, :out => out_w, :err => err_w, **kwargs)
115
223
  out_w.close
116
224
  err_w.close
117
225
 
118
226
  handlers = if block_given?
119
- { out_r => ->(data) { yield(data.force_encoding(Encoding::UTF_8), '') },
120
- err_r => ->(data) { yield('', data.force_encoding(Encoding::UTF_8)) } }
227
+ {
228
+ out_r => ->(data) { yield(data.force_encoding(Encoding::UTF_8), '') },
229
+ err_r => ->(data) { yield('', data.force_encoding(Encoding::UTF_8)) },
230
+ }
121
231
  else
122
- { out_r => ->(data) { STDOUT.write(data) },
123
- err_r => ->(data) { STDOUT.write(data) } }
232
+ {
233
+ out_r => ->(data) { STDOUT.write(data) },
234
+ err_r => ->(data) { STDOUT.write(data) },
235
+ }
124
236
  end
125
237
 
126
238
  previous_trailing = Hash.new('')
@@ -129,14 +241,12 @@ module CLI
129
241
  break if ios.empty?
130
242
 
131
243
  readers, = IO.select(ios)
132
- readers.each do |io|
133
- begin
134
- data, trailing = split_partial_characters(io.readpartial(4096))
135
- handlers[io].call(previous_trailing[io] + data)
136
- previous_trailing[io] = trailing
137
- rescue IOError
138
- io.close
139
- end
244
+ (readers || []).each do |io|
245
+ data, trailing = split_partial_characters(io.readpartial(4096))
246
+ handlers[io].call(previous_trailing[io] + data)
247
+ previous_trailing[io] = trailing
248
+ rescue IOError
249
+ io.close
140
250
  end
141
251
  end
142
252
 
@@ -147,35 +257,94 @@ module CLI
147
257
  # Split off trailing partial UTF-8 Characters. UTF-8 Multibyte characters start with a 11xxxxxx byte that tells
148
258
  # how many following bytes are part of this character, followed by some number of 10xxxxxx bytes. This simple
149
259
  # algorithm will split off a whole trailing multi-byte character.
260
+ sig { params(data: String).returns([String, String]) }
150
261
  def split_partial_characters(data)
151
- last_byte = data.getbyte(-1)
262
+ last_byte = T.must(data.getbyte(-1))
152
263
  return [data, ''] if (last_byte & 0b1000_0000).zero?
153
264
 
154
- # UTF-8 is up to 6 characters per rune, so we could never want to trim more than that, and we want to avoid
265
+ # UTF-8 is up to 4 characters per rune, so we could never want to trim more than that, and we want to avoid
155
266
  # allocating an array for the whole of data with bytes
156
- min_bound = -[6, data.bytesize].min
157
- final_bytes = data.byteslice(min_bound..-1).bytes
267
+ min_bound = -[4, data.bytesize].min
268
+ final_bytes = T.must(data.byteslice(min_bound..-1)).bytes
158
269
  partial_character_sub_index = final_bytes.rindex { |byte| byte & 0b1100_0000 == 0b1100_0000 }
270
+
159
271
  # Bail out for non UTF-8
160
272
  return [data, ''] unless partial_character_sub_index
273
+
274
+ start_byte = final_bytes[partial_character_sub_index]
275
+ full_size = if start_byte & 0b1111_1000 == 0b1111_0000
276
+ 4
277
+ elsif start_byte & 0b1111_0000 == 0b1110_0000
278
+ 3
279
+ elsif start_byte & 0b1110_0000 == 0b110_00000
280
+ 2
281
+ else
282
+ nil # Not a valid UTF-8 character
283
+ end
284
+ return [data, ''] if full_size.nil? # Bail out for non UTF-8
285
+
286
+ if final_bytes.size - partial_character_sub_index == full_size
287
+ # We have a full UTF-8 character, so we can just return the data
288
+ return [data, '']
289
+ end
290
+
161
291
  partial_character_index = min_bound + partial_character_sub_index
162
292
 
163
- [data.byteslice(0...partial_character_index), data.byteslice(partial_character_index..-1)]
293
+ [T.must(data.byteslice(0...partial_character_index)), T.must(data.byteslice(partial_character_index..-1))]
294
+ end
295
+
296
+ sig { returns(Symbol) }
297
+ def os
298
+ return :mac if /darwin/.match(RUBY_PLATFORM)
299
+ return :linux if /linux/.match(RUBY_PLATFORM)
300
+ return :windows if /mingw32/.match(RUBY_PLATFORM)
301
+
302
+ raise "Could not determine OS from platform #{RUBY_PLATFORM}"
303
+ end
304
+
305
+ sig { params(cmd: String, env: T::Hash[String, T.nilable(String)]).returns(T.nilable(String)) }
306
+ def which(cmd, env)
307
+ exts = os == :windows ? (env['PATHEXT'] || 'exe').split(';') : ['']
308
+ (env['PATH'] || '').split(File::PATH_SEPARATOR).each do |path|
309
+ exts.each do |ext|
310
+ exe = File.join(path, "#{cmd}#{ext}")
311
+ return exe if File.executable?(exe) && !File.directory?(exe)
312
+ end
313
+ end
314
+
315
+ nil
164
316
  end
165
317
 
166
318
  private
167
319
 
168
- def apply_sudo(*a, sudo)
169
- a.unshift('sudo', '-S', '-p', SUDO_PROMPT, '--') if sudo
320
+ sig do
321
+ params(cmd: String, args: T::Array[String], sudo: T.any(T::Boolean, String))
322
+ .returns([String, T::Array[String]])
323
+ end
324
+ def apply_sudo(cmd, args, sudo)
325
+ return [cmd, args] unless sudo
326
+
170
327
  sudo_reason(sudo) if sudo.is_a?(String)
171
- a
328
+ ['sudo', args.unshift('-E', '-S', '-p', SUDO_PROMPT, '--', cmd)]
172
329
  end
173
330
 
174
- def delegate_open3(*a, sudo: raise, env: raise, method: raise, **kwargs)
175
- a = apply_sudo(*a, sudo)
176
- Open3.send(method, env, *resolve_path(a, env), **kwargs)
331
+ sig do
332
+ params(
333
+ cmd: String,
334
+ args: T::Array[String],
335
+ kwargs: T::Hash[Symbol, T.untyped],
336
+ sudo: T.any(T::Boolean, String),
337
+ env: T::Hash[String, T.nilable(String)],
338
+ method: Symbol,
339
+ block: T.untyped,
340
+ ).returns(T.untyped)
341
+ end
342
+ def delegate_open3(cmd, args, kwargs, sudo: raise, env: raise, method: raise, &block)
343
+ cmd, args = apply_sudo(cmd, args, sudo)
344
+ cmd, args = resolve_path(cmd, args, env)
345
+ T.unsafe(Open3).send(method, env, cmd, *args, **kwargs, &block)
177
346
  rescue Errno::EINTR
178
- raise(Errno::EINTR, "command interrupted: #{a.join(' ')}")
347
+ raise(Errno::EINTR, "command interrupted: #{cmd} #{args.join(" ")}")
179
348
  end
180
349
 
181
350
  # Ruby resolves the program to execute using its own PATH, but we want it to
@@ -187,19 +356,19 @@ module CLI
187
356
  # project.
188
357
  #
189
358
  # See https://github.com/Shopify/dev/pull/625 for more details.
190
- def resolve_path(a, env)
359
+ sig do
360
+ params(cmd: String, args: T::Array[String], env: T::Hash[String, T.nilable(String)])
361
+ .returns([String, T::Array[String]])
362
+ end
363
+ def resolve_path(cmd, args, env)
191
364
  # If only one argument was provided, make sure it's interpreted by a shell.
192
- return ["true ; " + a[0]] if a.size == 1
193
- return a if a.first.include?('/')
194
-
195
- paths = env.fetch('PATH', '').split(':')
196
- item = paths.detect do |f|
197
- command_path = "#{f}/#{a.first}"
198
- File.executable?(command_path) && File.file?(command_path)
365
+ if args.empty?
366
+ prefix = os == :windows ? 'break && ' : 'true ; '
367
+ return [prefix + cmd, []]
199
368
  end
369
+ return [cmd, args] if cmd.include?('/')
200
370
 
201
- a[0] = "#{item}/#{a.first}" if item
202
- a
371
+ [which(cmd, env) || cmd, args]
203
372
  end
204
373
  end
205
374
  end