Skip to content

Commit 577fcc4

Browse files
committed
Merge pull request #360 from georgebrock/gb-readline
Use Readline for user input
2 parents 23ec5ba + 17eab3d commit 577fcc4

File tree

14 files changed

+392
-90
lines changed

14 files changed

+392
-90
lines changed

lib/thor/base.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require 'thor/invocation'
66
require 'thor/parser'
77
require 'thor/shell'
8+
require 'thor/line_editor'
89
require 'thor/util'
910

1011
class Thor

lib/thor/line_editor.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
require 'thor/line_editor/basic'
2+
require 'thor/line_editor/readline'
3+
4+
class Thor
5+
module LineEditor
6+
def self.readline(prompt, options={})
7+
best_available.new(prompt, options).readline
8+
end
9+
10+
def self.best_available
11+
[
12+
Thor::LineEditor::Readline,
13+
Thor::LineEditor::Basic
14+
].detect(&:available?)
15+
end
16+
end
17+
end

lib/thor/line_editor/basic.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
class Thor
2+
module LineEditor
3+
class Basic
4+
attr_reader :prompt, :options
5+
6+
def self.available?
7+
true
8+
end
9+
10+
def initialize(prompt, options)
11+
@prompt = prompt
12+
@options = options
13+
end
14+
15+
def readline
16+
$stdout.print(prompt)
17+
get_input
18+
end
19+
20+
private
21+
22+
def get_input
23+
if echo?
24+
$stdin.gets
25+
else
26+
$stdin.noecho(&:gets)
27+
end
28+
end
29+
30+
def echo?
31+
options.fetch(:echo, true)
32+
end
33+
end
34+
end
35+
end

lib/thor/line_editor/readline.rb

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
begin
2+
require 'readline'
3+
rescue LoadError
4+
end
5+
6+
class Thor
7+
module LineEditor
8+
class Readline < Basic
9+
def self.available?
10+
Object.const_defined?(:Readline)
11+
end
12+
13+
def readline
14+
if echo?
15+
::Readline.completion_append_character = nil
16+
::Readline.completion_proc = completion_proc
17+
::Readline.readline(prompt, add_to_history?)
18+
else
19+
super
20+
end
21+
end
22+
23+
private
24+
25+
def add_to_history?
26+
options.fetch(:add_to_history, true)
27+
end
28+
29+
def completion_proc
30+
if use_path_completion?
31+
Proc.new { |text| PathCompletion.new(text).matches }
32+
elsif completion_options.any?
33+
Proc.new do |text|
34+
completion_options.select { |option| option.start_with?(text) }
35+
end
36+
end
37+
end
38+
39+
def completion_options
40+
options.fetch(:limited_to, [])
41+
end
42+
43+
def use_path_completion?
44+
options.fetch(:path, false)
45+
end
46+
47+
class PathCompletion
48+
def initialize(text)
49+
@text = text
50+
end
51+
52+
def matches
53+
relative_matches
54+
end
55+
56+
private
57+
58+
attr_reader :text
59+
60+
def relative_matches
61+
absolute_matches.map { |path| path.sub(base_path, '') }
62+
end
63+
64+
def absolute_matches
65+
Dir[glob_pattern].map do |path|
66+
if File.directory?(path)
67+
"#{path}/"
68+
else
69+
path
70+
end
71+
end
72+
end
73+
74+
def glob_pattern
75+
"#{base_path}#{text}*"
76+
end
77+
78+
def base_path
79+
"#{Dir.pwd}/"
80+
end
81+
end
82+
end
83+
end
84+
end

lib/thor/shell/basic.rb

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,20 @@ def padding=(value)
4343
# If asking for sensitive information, the :echo option can be set
4444
# to false to mask user input from $stdin.
4545
#
46+
# If the required input is a path, then set the path option to
47+
# true. This will enable tab completion for file paths relative
48+
# to the current working directory on systems that support
49+
# Readline.
50+
#
4651
# ==== Example
4752
# ask("What is your name?")
4853
#
4954
# ask("What is your favorite Neopolitan flavor?", :limited_to => ["strawberry", "chocolate", "vanilla"])
5055
#
5156
# ask("What is your password?", :echo => false)
5257
#
58+
# ask("Where should the file be saved?", :path => true)
59+
#
5360
def ask(statement, *args)
5461
options = args.last.is_a?(Hash) ? args.pop : {}
5562
color = args.first
@@ -69,11 +76,7 @@ def ask(statement, *args)
6976
# say("I know you knew that.")
7077
#
7178
def say(message = '', color = nil, force_new_line = (message.to_s !~ /( |\t)\Z/))
72-
message = message.to_s
73-
message = set_color(message, *color) if color && can_display_colors?
74-
75-
buffer = ' ' * padding
76-
buffer << message
79+
buffer = prepare_message(message, *color)
7780
buffer << "\n" if force_new_line && !message.end_with?("\n")
7881

7982
stdout.print(buffer)
@@ -104,14 +107,14 @@ def say_status(status, message, log_status = true)
104107
# "yes".
105108
#
106109
def yes?(statement, color = nil)
107-
!!(ask(statement, color) =~ is?(:yes))
110+
!!(ask(statement, color, :add_to_history => false) =~ is?(:yes))
108111
end
109112

110113
# Make a question the to user and returns true if the user replies "n" or
111114
# "no".
112115
#
113116
def no?(statement, color = nil)
114-
!!(ask(statement, color) =~ is?(:no))
117+
!!(ask(statement, color, :add_to_history => false) =~ is?(:no))
115118
end
116119

117120
# Prints values in columns
@@ -231,7 +234,10 @@ def file_collision(destination) # rubocop:disable MethodLength
231234
options = block_given? ? '[Ynaqdh]' : '[Ynaqh]'
232235

233236
loop do
234-
answer = ask %[Overwrite #{destination}? (enter "h" for help) #{options}]
237+
answer = ask(
238+
%[Overwrite #{destination}? (enter "h" for help) #{options}],
239+
:add_to_history => false
240+
)
235241

236242
case answer
237243
when is?(:yes), is?(:force), ''
@@ -283,6 +289,11 @@ def set_color(string, *args) #:nodoc:
283289

284290
protected
285291

292+
def prepare_message(message, *color)
293+
spaces = " " * padding
294+
spaces + set_color(message.to_s, *color)
295+
end
296+
286297
def can_display_colors?
287298
false
288299
end
@@ -296,10 +307,6 @@ def stdout
296307
$stdout
297308
end
298309

299-
def stdin
300-
$stdin
301-
end
302-
303310
def stderr
304311
$stderr
305312
end
@@ -383,13 +390,8 @@ def as_unicode
383390
def ask_simply(statement, color, options)
384391
default = options[:default]
385392
message = [statement, ("(#{default})" if default), nil].uniq.join(' ')
386-
say(message, color)
387-
388-
result = if options[:echo] == false
389-
stdin.noecho(&:gets)
390-
else
391-
stdin.gets
392-
end
393+
message = prepare_message(message, color)
394+
result = Thor::LineEditor.readline(message, options)
393395

394396
return unless result
395397

lib/thor/shell/color.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ class Color < Basic
7777
# :on_cyan
7878
# :on_white
7979
def set_color(string, *colors)
80-
if colors.all? { |color| color.is_a?(Symbol) || color.is_a?(String) }
80+
if colors.compact.empty? || !can_display_colors?
81+
string
82+
elsif colors.all? { |color| color.is_a?(Symbol) || color.is_a?(String) }
8183
ansi_colors = colors.map { |color| lookup_color(color) }
8284
"#{ansi_colors.join}#{string}#{CLEAR}"
8385
else

spec/actions/create_file_spec.rb

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,32 +101,30 @@ def silence!
101101
end
102102

103103
it 'shows conflict status to the user' do
104-
expect(create_file('doc/config.rb')).not_to be_identical
105-
expect($stdin).to receive(:gets).and_return('s')
106104
file = File.join(destination_root, 'doc/config.rb')
105+
expect(create_file('doc/config.rb')).not_to be_identical
106+
expect(Thor::LineEditor).to receive(:readline).with("Overwrite #{file}? (enter \"h\" for help) [Ynaqdh] ", anything).and_return('s')
107107

108108
content = invoke!
109109
expect(content).to match(/conflict doc\/config\.rb/)
110-
expect(content).to match(/Overwrite #{file}\? \(enter "h" for help\) \[Ynaqdh\]/)
111110
expect(content).to match(/skip doc\/config\.rb/)
112111
end
113112

114113
it 'creates the file if the file collision menu returns true' do
115114
create_file('doc/config.rb')
116-
expect($stdin).to receive(:gets).and_return('y')
115+
expect(Thor::LineEditor).to receive(:readline).and_return('y')
117116
expect(invoke!).to match(/force doc\/config\.rb/)
118117
end
119118

120119
it 'skips the file if the file collision menu returns false' do
121120
create_file('doc/config.rb')
122-
expect($stdin).to receive(:gets).and_return('n')
121+
expect(Thor::LineEditor).to receive(:readline).and_return('n')
123122
expect(invoke!).to match(/skip doc\/config\.rb/)
124123
end
125124

126125
it 'executes the block given to show file content' do
127126
create_file('doc/config.rb')
128-
expect($stdin).to receive(:gets).and_return('d')
129-
expect($stdin).to receive(:gets).and_return('n')
127+
expect(Thor::LineEditor).to receive(:readline).and_return('d', 'n')
130128
expect(@base.shell).to receive(:system).with(/diff -u/)
131129
invoke!
132130
end

spec/helper.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,14 @@ def destination_root
6767
File.join(File.dirname(__FILE__), 'sandbox')
6868
end
6969

70+
# This code was adapted from Ruby on Rails, available under MIT-LICENSE
71+
# Copyright (c) 2004-2013 David Heinemeier Hansson
72+
def silence_warnings
73+
old_verbose, $VERBOSE = $VERBOSE, nil
74+
yield
75+
ensure
76+
$VERBOSE = old_verbose
77+
end
78+
7079
alias silence capture
7180
end

spec/line_editor/basic_spec.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
require 'helper'
2+
3+
describe Thor::LineEditor::Basic do
4+
describe '.available?' do
5+
it 'returns true' do
6+
expect(Thor::LineEditor::Basic).to be_available
7+
end
8+
end
9+
10+
describe '#readline' do
11+
it 'uses $stdin and $stdout to get input from the user' do
12+
expect($stdout).to receive(:print).with('Enter your name ')
13+
expect($stdin).to receive(:gets).and_return('George')
14+
expect($stdin).not_to receive(:noecho)
15+
editor = Thor::LineEditor::Basic.new('Enter your name ', {})
16+
expect(editor.readline).to eq('George')
17+
end
18+
19+
it 'disables echo when asked to' do
20+
expect($stdout).to receive(:print).with('Password: ')
21+
noecho_stdin = double('noecho_stdin')
22+
expect(noecho_stdin).to receive(:gets).and_return('secret')
23+
expect($stdin).to receive(:noecho).and_yield(noecho_stdin)
24+
editor = Thor::LineEditor::Basic.new('Password: ', :echo => false)
25+
expect(editor.readline).to eq('secret')
26+
end
27+
end
28+
end

0 commit comments

Comments
 (0)