Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 90 additions & 2 deletions lib/bundler/cli/common.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,96 @@ def self.ask_for_spec_from(specs)
end
Bundler.ui.info "0 : - exit -", true

num = Bundler.ui.ask("> ").to_i
num > 0 ? specs[num - 1] : nil
num = ask_for_number(specs.count)
num && num > 0 ? specs[num - 1] : nil
end

# Reads a menu selection in the range 0..max, returning the chosen number
# or nil if no selection was made (Ctrl-D/EOF). When stdin/stdout are a TTY,
# a choice is accepted on a single keypress as soon as it is unambiguous,
# i.e. no larger valid number has the typed digits as a prefix (so "1" is
# accepted instantly when there are < 10 options, but waits for a second
# digit when "10"/"11"/... are also valid). Enter resolves the prefix to
# the number typed so far (e.g. selecting "1" while "10" exists), backspace
# edits, and Ctrl-C aborts (exit 130). Falls back to a line-based prompt otherwise.
def self.ask_for_number(max)
return Bundler.ui.ask("> ").to_i unless single_keypress_supported?

buf = String.new
# The prompt, echo and newlines are written straight to $stdout (rather
# than through Bundler.ui) because this path only runs on an interactive
# TTY, where we need precise, unbuffered control over the cursor.
$stdout.print "> "
$stdout.flush

loop do
ch = $stdin.getch
# "No selection" -- the same outcome as choosing 0/exit -- via either a
# real EOF (nil, e.g. the terminal detached) or Ctrl-D, which getch's
# raw mode delivers as a byte (4 = EOT) rather than as EOF.
if ch.nil? || ch == 4.chr
$stdout.print "\n"
return
end
# Ctrl-C arrives as a raw byte because getch disables the terminal's
# signal keys. Re-raising Interrupt here would be re-rescued and printed
# by with_friendly_errors; instead exit with the conventional SIGINT
# status (130) so Bundler still terminates with proper signal semantics
# (see rubygems/bundler#6092) but without dumping a backtrace.
if ch == 3.chr
$stdout.print "\n"
exit(128 + Signal.list["INT"])
end

case ch
when "\r", "\n"
if valid_number?(buf, max)
$stdout.print "\n"
return buf.to_i
end
when "\u007f", "\b" # backspace / delete
unless buf.empty?
buf.chop!
$stdout.print "\b \b"
$stdout.flush
end
when /\A[0-9]\z/
candidate = buf + ch
# Ignore a digit that cannot lead to any valid selection.
next unless prefix_of_valid?(candidate, max)
buf = candidate
$stdout.print ch
$stdout.flush
if valid_number?(buf, max) && !has_longer_valid?(buf, max)
$stdout.print "\n"
return buf.to_i
end
end
end
end

def self.single_keypress_supported?
return false unless $stdin.tty? && $stdout.tty?
require "io/console"
$stdin.respond_to?(:getch)
rescue LoadError
false
end

# buf is exactly a valid token (no leading zeros) within 0..max.
def self.valid_number?(buf, max)
!buf.empty? && buf == buf.to_i.to_s && buf.to_i <= max
end

# Some valid token in 0..max starts with the typed digits (incl. equal).
def self.prefix_of_valid?(buf, max)
(0..max).any? {|i| i.to_s.start_with?(buf) }
end

# A *longer* valid token in 0..max starts with the typed digits, so the
# selection is still ambiguous (e.g. "1" while "10" is also valid).
def self.has_longer_valid?(buf, max)
(0..max).any? {|i| i.to_s != buf && i.to_s.start_with?(buf) }
end

def self.gem_not_found_message(missing_gem_name, alternatives)
Expand Down
80 changes: 80 additions & 0 deletions spec/bundler/cli_common_spec.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "bundler/cli"
require "stringio"

RSpec.describe Bundler::CLI::Common do
describe "gem_not_found_message" do
Expand All @@ -19,4 +20,83 @@
expect(message).to match(/Did you mean 'method(|s)' or 'method(|s)'?/)
end
end

describe "ask_for_number" do
BACKSPACE = 127.chr # DEL
CTRL_C = 3.chr # ETX
CTRL_D = 4.chr # EOT

# Run ask_for_number against a scripted sequence of keypresses, with the
# single-keypress path forced on and $stdin/$stdout swapped for fakes.
def select(keys, max)
allow(described_class).to receive(:single_keypress_supported?).and_return(true)
input = double("stdin")
allow(input).to receive(:getch).and_return(*keys)

old_stdin = $stdin
old_stdout = $stdout
$stdin = input
$stdout = StringIO.new
described_class.ask_for_number(max)
ensure
$stdin = old_stdin
$stdout = old_stdout
end

context "when no option number is a prefix of another (fewer than ten options)" do
it "accepts a choice on a single keypress, without Enter" do
expect(select(["1"], 3)).to eq(1)
expect(select(["3"], 3)).to eq(3)
end

it "treats 0 as the exit choice" do
expect(select(["0"], 3)).to eq(0)
end

it "ignores a digit that cannot match any option" do
expect(select(["8", "2"], 3)).to eq(2)
end
end

context "when an option number is a prefix of another (ten or more options)" do
it "waits for a second digit before resolving an ambiguous prefix" do
expect(select(["1", "0"], 11)).to eq(10)
expect(select(["1", "1"], 11)).to eq(11)
end

it "still accepts an unambiguous digit immediately" do
expect(select(["7"], 11)).to eq(7)
expect(select(["0"], 11)).to eq(0)
end

it "resolves the shorter number when Enter is pressed" do
expect(select(["1", "\r"], 11)).to eq(1)
expect(select(["1", "\n"], 11)).to eq(1)
end
end

it "supports backspace to edit the buffer" do
expect(select(["1", BACKSPACE, "3"], 11)).to eq(3)
end

it "aborts with the conventional SIGINT status (130) on Ctrl-C" do
expect { select([CTRL_C], 3) }.to raise_error(SystemExit) do |error|
expect(error.status).to eq(130)
end
end

it "returns nil at EOF instead of looping" do
expect(select([nil], 3)).to be_nil
end

it "cancels (returns nil) on Ctrl-D" do
expect(select([CTRL_D], 3)).to be_nil
end

it "falls back to a line prompt when single keypress input is unsupported" do
allow(described_class).to receive(:single_keypress_supported?).and_return(false)
allow(Bundler.ui).to receive(:ask).with("> ").and_return("2")
expect(described_class.ask_for_number(3)).to eq(2)
end
end
end