-
Notifications
You must be signed in to change notification settings - Fork 53
/
Copy pathprompt.rb
404 lines (363 loc) · 14.9 KB
/
prompt.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# coding: utf-8
# typed: true
# frozen_string_literal: true
require 'cli/ui'
begin
require 'reline' # For 2.7+
rescue LoadError
require 'readline' # For 2.6
Object.const_set(:Reline, Readline)
end
module CLI
module UI
module Prompt
autoload :InteractiveOptions, 'cli/ui/prompt/interactive_options'
autoload :OptionsHandler, 'cli/ui/prompt/options_handler'
class << self
extend T::Sig
sig { returns(Color) }
def instructions_color
@instructions_color ||= Color::YELLOW
end
# Set the instructions color.
#
# ==== Attributes
#
# * +color+ - the color to use for prompt instructions
#
sig { params(color: Colorable).void }
def instructions_color=(color)
@instructions_color = CLI::UI.resolve_color(color)
end
# Ask a user a question with either free form answer or a set of answers (multiple choice)
# Can use arrows, y/n, numbers (1/2), and vim bindings to control multiple choice selection
# Do not use this method for yes/no questions. Use +confirm+
#
# * Handles free form answers (options are nil)
# * Handles default answers for free form text
# * Handles file auto completion for file input
# * Handles interactively choosing answers using +InteractiveOptions+
#
# https://user-images.githubusercontent.com/3074765/33799822-47f23302-dd01-11e7-82f3-9072a5a5f611.png
#
# ==== Attributes
#
# * +question+ - (required) The question to ask the user
#
# ==== Options
#
# * +:options+ - Options that the user may select from. Will use +InteractiveOptions+ to do so.
# * +:default+ - The default answer to the question (e.g. they just press enter and don't input anything)
# * +:is_file+ - Tells the input to use file auto-completion (tab completion)
# * +:allow_empty+ - Allows the answer to be empty
# * +:multiple+ - Allow multiple options to be selected
# * +:filter_ui+ - Enable option filtering (default: true)
# * +:select_ui+ - Enable long-form option selection (default: true)
#
# Note:
# * +:options+ or providing a +Block+ conflicts with +:default+ and +:is_file+,
# you cannot set options with either of these keywords
# * +:default+ conflicts with +:allow_empty:, you cannot set these together
# * +:options+ conflicts with providing a +Block+ , you may only set one
# * +:multiple+ can only be used with +:options+ or a +Block+; it is ignored, otherwise.
#
# ==== Block (optional)
#
# * A Proc that provides a +OptionsHandler+ and uses the public +:option+ method to add options and their
# respective handlers
#
# ==== Return Value
#
# * If a +Block+ was not provided, the selected option or response to the free form question will be returned
# * If a +Block+ was provided, the evaluated value of the +Block+ will be returned
#
# ==== Example Usage:
#
# Free form question
# CLI::UI::Prompt.ask('What color is the sky?')
#
# Free form question with a file answer
# CLI::UI::Prompt.ask('Where is your Gemfile located?', is_file: true)
#
# Free form question with a default answer
# CLI::UI::Prompt.ask('What color is the sky?', default: 'blue')
#
# Free form question when the answer can be empty
# CLI::UI::Prompt.ask('What is your opinion on this question?', allow_empty: true)
#
# Interactive (multiple choice) question
# CLI::UI::Prompt.ask('What kind of project is this?', options: %w(rails go ruby python))
#
# Interactive (multiple choice) question with defined handlers
# CLI::UI::Prompt.ask('What kind of project is this?') do |handler|
# handler.option('rails') { |selection| selection }
# handler.option('go') { |selection| selection }
# handler.option('ruby') { |selection| selection }
# handler.option('python') { |selection| selection }
# end
#
sig do
params(
question: String,
options: T.nilable(T::Array[String]),
default: T.nilable(T.any(String, T::Array[String])),
is_file: T::Boolean,
allow_empty: T::Boolean,
multiple: T::Boolean,
filter_ui: T::Boolean,
select_ui: T::Boolean,
options_proc: T.nilable(T.proc.params(handler: OptionsHandler).void),
).returns(T.any(String, T::Array[String]))
end
def ask(
question,
options: nil,
default: nil,
is_file: false,
allow_empty: true,
multiple: false,
filter_ui: true,
select_ui: true,
&options_proc
)
has_options = !!(options || block_given?)
if has_options && is_file
raise(ArgumentError, 'conflicting arguments: is_file is only useful when options are not provided')
end
if options && multiple && default && !(Array(default) - options).empty?
raise(ArgumentError, 'conflicting arguments: default should only include elements present in options')
end
if multiple && !has_options
raise(ArgumentError, 'conflicting arguments: options must be provided when multiple is true')
end
if !multiple && default.is_a?(Array)
raise(ArgumentError, 'conflicting arguments: multiple defaults may only be provided when multiple is true')
end
if has_options
ask_interactive(
question,
options,
multiple: multiple,
default: default,
filter_ui: filter_ui,
select_ui: select_ui,
&options_proc
)
else
ask_free_form(question, T.cast(default, T.nilable(String)), is_file, allow_empty)
end
end
# Asks the user for a single-line answer, without displaying the characters while typing.
# Typically used for password prompts
#
# ==== Return Value
#
# The password, without a trailing newline.
# If the user simply presses "Enter" without typing any password, this will return an empty string.
sig { params(question: String).returns(String) }
def ask_password(question)
require 'io/console'
CLI::UI::StdoutRouter::Capture.in_alternate_screen do
$stdout.print(CLI::UI.fmt('{{?}} ' + question)) # Do not use puts_question to avoid the new line.
# noecho interacts poorly with Readline under system Ruby, so do a manual `gets` here.
# No fancy Readline integration (like echoing back) is required for a password prompt anyway.
password = $stdin.noecho do
# Chomp will remove the one new line character added by `gets`, without touching potential extra spaces:
# " 123 \n".chomp => " 123 "
$stdin.gets.to_s.chomp
end
$stdout.puts # Complete the line
password
end
end
# Asks the user a yes/no question.
# Can use arrows, y/n, numbers (1/2), and vim bindings to control
#
# ==== Example Usage:
#
# Confirmation question
# CLI::UI::Prompt.confirm('Is the sky blue?')
#
# CLI::UI::Prompt.confirm('Do a dangerous thing?', default: false)
#
sig { params(question: String, default: T::Boolean).returns(T::Boolean) }
def confirm(question, default: true)
ask_interactive(question, default ? ['yes', 'no'] : ['no', 'yes'], filter_ui: false) == 'yes'
end
# Present the user with a message and wait for any key to be pressed, returning the pressed key.
#
# ==== Example Usage:
#
# CLI::UI::Prompt.any_key # Press any key to continue...
#
# CLI::UI::Prompt.any_key('Press RETURN to continue...') # Then check if that's what they pressed
sig { params(prompt: String).returns(T.nilable(String)) }
def any_key(prompt = 'Press any key to continue...')
CLI::UI::StdoutRouter::Capture.in_alternate_screen do
puts_question(prompt)
read_char
end
end
# Wait for any key to be pressed, returning the pressed key.
sig { returns(T.nilable(String)) }
def read_char
CLI::UI::StdoutRouter::Capture.in_alternate_screen do
if $stdin.tty? && !ENV['TEST']
require 'io/console'
$stdin.getch # raw mode for tty
else
$stdin.getc # returns nil at end of input
end
end
rescue Errno::EIO, Errno::EPIPE, IOError
"\e"
end
private
sig do
params(question: String, default: T.nilable(String), is_file: T::Boolean, allow_empty: T::Boolean)
.returns(String)
end
def ask_free_form(question, default, is_file, allow_empty)
if default && !allow_empty
raise(ArgumentError, 'conflicting arguments: default enabled but allow_empty is false')
end
CLI::UI::StdoutRouter::Capture.in_alternate_screen do
if default
puts_question("#{question} (empty = #{default})")
else
puts_question(question)
end
# Ask a free form question
loop do
line = readline(is_file: is_file)
if line.empty? && default
write_default_over_empty_input(default)
return default
end
if !line.empty? || allow_empty
return line
end
end
end
end
sig do
params(
question: String,
options: T.nilable(T::Array[String]),
multiple: T::Boolean,
default: T.nilable(T.any(String, T::Array[String])),
filter_ui: T::Boolean,
select_ui: T::Boolean,
).returns(T.any(String, T::Array[String]))
end
def ask_interactive(question, options = nil, multiple: false, default: nil, filter_ui: true, select_ui: true)
raise(ArgumentError, 'conflicting arguments: options and block given') if options && block_given?
options ||= if block_given?
handler = OptionsHandler.new
yield handler
handler.options
end
raise(ArgumentError, 'insufficient options') if options.nil? || options.empty?
navigate_text = if CLI::UI::OS.current.suggest_arrow_keys?
'Choose with ↑ ↓ ⏎'
else
"Navigate up with 'k' and down with 'j', press Enter to select"
end
instructions = (multiple ? 'Toggle options. ' : '') + navigate_text
instructions += ", filter with 'f'" if filter_ui
instructions += ", enter option with 'e'" if select_ui && (options.size > 9)
resp = T.let([], T.any(String, T::Array[String]))
CLI::UI::StdoutRouter::Capture.in_alternate_screen do
puts_question("#{question} " + instructions_color.code + "(#{instructions})" + Color::RESET.code)
resp = interactive_prompt(options, multiple: multiple, default: default)
# Clear the line
print(ANSI.previous_line + ANSI.clear_to_end_of_line)
# Force StdoutRouter to prefix
print(ANSI.previous_line + "\n")
# reset the question to include the answer
resp_text = case resp
when Array
case resp.size
when 0
'<nothing>'
when 1..2
resp.join(' and ')
else
"#{resp.size} items"
end
else
resp
end
puts_question("#{question} (You chose: {{italic:#{resp_text}}})")
end
if block_given?
T.must(handler).call(resp)
else
resp
end
end
# Useful for stubbing in tests
sig do
params(options: T::Array[String], multiple: T::Boolean, default: T.nilable(T.any(T::Array[String], String)))
.returns(T.any(T::Array[String], String))
end
def interactive_prompt(options, multiple: false, default: nil)
CLI::UI::StdoutRouter::Capture.in_alternate_screen do
InteractiveOptions.call(options, multiple: multiple, default: default)
end
end
sig { params(default: String).void }
def write_default_over_empty_input(default)
CLI::UI.raw do
$stderr.puts(
CLI::UI::ANSI.cursor_up(1) +
"\r" +
CLI::UI::ANSI.cursor_forward(4) + # TODO: width
default +
CLI::UI::Color::RESET.code,
)
end
end
sig { params(str: String).void }
def puts_question(str)
$stdout.puts(CLI::UI.fmt('{{?}} ' + str))
end
sig { params(is_file: T::Boolean).returns(String) }
def readline(is_file: false)
if is_file
Reline.completion_proc = proc do |input|
directory = input[-1] == '/' ? input : File.dirname(input)
filename = input[-1] == '/' ? '' : File.basename(input)
(Dir.entries(directory).select do |fp|
fp.start_with?(filename)
end - (input[-1] == '.' ? [] : ['.', '..'])).map do |fp|
File.join(directory, fp).gsub(/\A\.\//, '')
end
end
Reline.completion_append_character = ''
else
Reline.completion_proc = proc {}
Reline.completion_append_character = ' '
end
# because Readline is a C library, CLI::UI's hooks into $stdout don't
# work. We could work around this by having CLI::UI use a pipe and a
# thread to manage output, but the current strategy feels like a
# better tradeoff.
prefix = CLI::UI::Frame.prefix
# If a prompt is interrupted on Windows it locks the colour of the terminal from that point on, so we should
# not change the colour here.
prompt = prefix + CLI::UI.fmt('{{blue:> }}')
prompt += CLI::UI::Color::YELLOW.code if CLI::UI::OS.current.use_color_prompt?
begin
line = Reline.readline(prompt, true)
print(CLI::UI::Color::RESET.code)
line.to_s.chomp
rescue Interrupt
CLI::UI.raw { $stderr.puts('^C' + CLI::UI::Color::RESET.code) }
raise
end
end
end
end
end
end