Skip to content

Commit

Permalink
Shell interactions
Browse files Browse the repository at this point in the history
  • Loading branch information
timotheeguerin committed Jun 5, 2015
1 parent 82a733e commit 895e299
Show file tree
Hide file tree
Showing 9 changed files with 234 additions and 124 deletions.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -3,6 +3,7 @@
[![Coverage Status](https://coveralls.io/repos/timcolonel/clin/badge.svg?branch=master)](https://coveralls.io/r/timcolonel/clin?branch=master)
[![Code Climate](https://codeclimate.com/github/timcolonel/clin/badges/gpa.svg)](https://codeclimate.com/github/timcolonel/clin)
[![Inline docs](http://inch-ci.org/github/timcolonel/clin.svg?branch=master)](http://inch-ci.org/github/timcolonel/clin)

Clin is Command Line Interface library that provide an clean api for complex command configuration.
The way Clin is design allow a command defined by the user to be called via the command line as well as directly in the code without any additional configuration
## Installation
Expand Down
7 changes: 6 additions & 1 deletion lib/clin/option.rb
Expand Up @@ -24,7 +24,12 @@ def parse(name, usage, &block)
end

attr_accessor :name, :description, :optional_argument, :block, :type, :default
attr_writer :short, :long

# Set the short name(e.g. -v for verbose)
attr_writer :short

# Set the long name(e.g. --verbose for verbose)
attr_writer :long

# Create a new option.
# @param name [String] Option name.
Expand Down
113 changes: 8 additions & 105 deletions lib/clin/shell.rb
Expand Up @@ -36,19 +36,8 @@ def ask(statement, default: nil)
# If multiple choices start with the same initial
# ONLY the first one will be able to be selected using its inital
def choose(statement, choices, default: nil, allow_initials: false)
choices = convert_choices(choices)
question = prepare_question(statement, choices, default: default, initials: allow_initials)
loop do
answer = ask(question, default: default)
unless answer.nil?
choices.each do |choice, _|
if choice.casecmp(answer) == 0 || (allow_initials && choice[0].casecmp(answer[0]) == 0)
return choice
end
end
end
print_choices_help(choices, allow_initials: allow_initials)
end
Clin::ShellInteraction::Choose.new(self).run(statement, choices,
default: default, allow_initials: allow_initials)
end

# Expect the user the return yes or no(y/n also works)
Expand All @@ -57,32 +46,22 @@ def choose(statement, choices, default: nil, allow_initials: false)
# @param persist [Boolean] Add "always" to the choices. When all is selected all the following
# call to yes_or_no with persist: true will return true instead of asking the user.
def yes_or_no(statement, default: nil, persist: false)
options = %w(yes no)
if persist
return true if @yes_or_no_persist
options << 'always'
end
choice = choose(statement, options, default: default, allow_initials: true)
if choice == 'always'
choice = 'yes'
@yes_or_no_persist = true
end
choice == 'yes'
Clin::ShellInteraction::YesOrNo.new(self).run(statement, default: default, persist: persist)
end

# Yes or no question defaulted to yes
# @param options [Hash] Named parameters for yes_or_no
# @see #yes_or_no
def yes?(statement, options = {})
options[:default] = 'yes'
options[:default] = :yes
yes_or_no(statement, **options)
end

# Yes or no question defaulted to no
# @param options [Hash] Named parameters for yes_or_no
# @see #yes_or_no
def no?(statement, options = {})
options[:default] = 'no'
options[:default] = :no
yes_or_no(statement, **options)
end

Expand All @@ -97,28 +76,7 @@ def no?(statement, options = {})
# @param block [Block] optional block that give the new content in case of diff
# @return [Boolean] If the file should be overwritten.
def file_conflict(filename, default: nil, &block)
choices = file_conflict_choices
choices = choices.except(:diff) unless block_given?
return true if @override_persist
loop do
result = choose("Overwrite '#{filename}'?", choices, default: default, allow_initials: true)
case result
when :yes
return true
when :no
return false
when :always
return @override_persist = true
when :quit
puts 'Aborting...'
fail SystemExit
when :diff
show_diff(filename, block.call)
next
else
next
end
end
Clin::ShellInteraction::FileConflict.new(self).run(filename, default: default, &block)
end

# File conflict question defaulted to yes
Expand All @@ -135,61 +93,6 @@ def keep?(filename, &block)
@out.print(statement + ' ')
@in.gets
end

protected def choice_message(choices, default: nil, initials: false)
choices = choices.keys.map { |x| x == default ? x.to_s.upcase : x }
msg = if initials
choices.map { |x| x[0] }.join('')
else
choices.join(',')
end
"[#{msg}]"
end

protected def prepare_question(statement, choices, default: nil, initials: false)
question = statement.clone
question << " #{choice_message(choices, default: default, initials: initials)}"
end

protected def print_choices_help(choices, allow_initials: false)
puts 'Choose from:'
used_initials = Set.new
choices.each do |choice, description|
suf = choice.to_s
suf += ", #{description}" unless description.blank?
line = if allow_initials && !used_initials.include?(choice[0])
used_initials << choice[0]
" #{choice[0]} - #{suf}"
else
" #{suf}"
end
puts line
end
end

# Convert the choices to a hash with key being the choice and value the description
protected def convert_choices(choices)
if choices.is_a? Array
Hash[*choices.map { |k| [k, ''] }.flatten]
elsif choices.is_a? Hash
choices
end
end

protected def file_conflict_choices
{yes: 'Overwrite',
no: 'Do not Overwrite',
always: 'Override this and all the next',
quit: 'Abort!',
diff: 'Show the difference',
help: 'Show this'}
end

protected def show_diff(old_file, new_content)
Tempfile.open(File.basename(old_file)) do |f|
f.write new_content
f.rewind
system %(#{Clin.diff_cmd} "#{old_file}" "#{f.path}")
end
end
end

require 'clin/shell_interaction'
34 changes: 34 additions & 0 deletions lib/clin/shell_interaction.rb
@@ -0,0 +1,34 @@
require 'clin'

# Parent class for shell interaction.
class Clin::ShellInteraction
class << self
attr_accessor :persist
end

attr_accessor :shell

# @param shell [Clin::Shell] Shell starting the interaction.
def initialize(shell)
@shell = shell
self.class.persist ||= {}
end

# @return [Boolean]
def persist?
self.class.persist[@shell] ||= false
end

# Mark the current shell to persist file interaction
def persist!
self.class.persist[@shell] = persist_answer
end

def persist_answer
true
end
end

require 'clin/shell_interaction/file_conflict'
require 'clin/shell_interaction/yes_or_no'
require 'clin/shell_interaction/choose'
59 changes: 59 additions & 0 deletions lib/clin/shell_interaction/choose.rb
@@ -0,0 +1,59 @@
require 'clin'

# Handle a choose question
class Clin::ShellInteraction::Choose < Clin::ShellInteraction
def run(statement, choices, default: nil, allow_initials: false)
choices = convert_choices(choices)
question = prepare_question(statement, choices, default: default, initials: allow_initials)
loop do
answer = @shell.ask(question, default: default)
unless answer.nil?
choices.each do |choice, _|
if choice.casecmp(answer) == 0 || (allow_initials && choice[0].casecmp(answer[0]) == 0)
return choice
end
end
end
print_choices_help(choices, allow_initials: allow_initials)
end
end

protected def choice_message(choices, default: nil, initials: false)
choices = choices.keys.map { |x| x == default ? x.to_s.upcase : x }
msg = if initials
choices.map { |x| x[0] }.join('')
else
choices.join(',')
end
"[#{msg}]"
end

protected def prepare_question(statement, choices, default: nil, initials: false)
"#{statement} #{choice_message(choices, default: default, initials: initials)}"
end

# Convert the choices to a hash with key being the choice and value the description
protected def convert_choices(choices)
if choices.is_a? Array
Hash[*choices.map { |k| [k, ''] }.flatten]
elsif choices.is_a? Hash
choices
end
end

protected def print_choices_help(choices, allow_initials: false)
puts 'Choose from:'
used_initials = Set.new
choices.each do |choice, description|
suf = choice.to_s
suf += ", #{description}" unless description.blank?
line = if allow_initials && !used_initials.include?(choice[0])
used_initials << choice[0]
" #{choice[0]} - #{suf}"
else
" #{suf}"
end
puts line
end
end
end
53 changes: 53 additions & 0 deletions lib/clin/shell_interaction/file_conflict.rb
@@ -0,0 +1,53 @@
require 'clin'

# Handle the file_conflict interaction with the user.
class Clin::ShellInteraction::FileConflict < Clin::ShellInteraction
def run(filename, default: nil, &block)
choices = file_conflict_choices
choices = choices.except(:diff) unless block_given?
return true if persist?
result = nil
while result.nil?
choice = @shell.choose("Overwrite '#{filename}'?", choices,
default: default, allow_initials: true)
result = handle_choice(choice, filename, &block)
end
result
end

protected def handle_choice(choice, filename, &block)
case choice
when :yes
return true
when :no
return false
when :always
return persist!
when :quit
puts 'Aborting...'
fail SystemExit
when :diff
show_diff(filename, block.call)
return nil
else
return nil
end
end

protected def file_conflict_choices
{yes: 'Overwrite',
no: 'Do not Overwrite',
always: 'Override this and all the next',
quit: 'Abort!',
diff: 'Show the difference',
help: 'Show this'}
end

protected def show_diff(old_file, new_content)
Tempfile.open(File.basename(old_file)) do |f|
f.write new_content
f.rewind
system %(#{Clin.diff_cmd} "#{old_file}" "#{f.path}")
end
end
end
16 changes: 16 additions & 0 deletions lib/clin/shell_interaction/yes_or_no.rb
@@ -0,0 +1,16 @@
require 'clin'

# Handle a simple yes/no interaction
class Clin::ShellInteraction::YesOrNo < Clin::ShellInteraction
def run(statement, default: nil, persist: false)
default = default.to_sym unless default.nil?
options = [:yes, :no]
if persist
return true if persist?
options << :always
end
choice = @shell.choose(statement, options, default: default, allow_initials: true)
return persist! if choice == :always
choice == :yes
end
end
53 changes: 53 additions & 0 deletions spec/clin/shell_interaction/choose_spec.rb
@@ -0,0 +1,53 @@
require 'clin'

RSpec.describe Clin::ShellInteraction::Choose do
let(:shell) { Clin::Shell.new }
subject { Clin::ShellInteraction::Choose.new(shell) }

def expects_scan(message, *outputs)
expect(shell).to receive(:scan).with(message).and_return(*outputs).exactly(outputs.size).times
end

describe '#choice_message' do
let(:options) { {yes: '', no: '', maybe: ''} }
it { expect(subject.send(:choice_message, options)).to eq('[yes,no,maybe]') }
it { expect(subject.send(:choice_message, options, default: :yes)).to eq('[YES,no,maybe]') }
it { expect(subject.send(:choice_message, options, default: :no)).to eq('[yes,NO,maybe]') }
it { expect(subject.send(:choice_message, options, initials: true)).to eq('[ynm]') }
it { expect(subject.send(:choice_message, options, default: :yes, initials: true)).to eq('[Ynm]') }
it { expect(subject.send(:choice_message, options, default: :maybe, initials: true)).to eq('[ynM]') }
end

describe '#prepare_question' do
let(:options) { {yes: '', no: '', maybe: ''} }
it do
expect(subject.send(:prepare_question, 'Is it true?', options))
.to eq('Is it true? [yes,no,maybe]')
end
end

describe '#run' do
let(:countries) { %w(usa france germany italy) }

it 'ask for a choice and return the user reply' do
expects_scan('Where are you from? [usa,france,germany,italy]', 'France')
expect(subject.run('Where are you from?', countries)).to eq('france')
end

it 'ask for a choice and return the default' do
expects_scan('Where are you from? [usa,france,GERMANY,italy]', '')
expect(subject.run('Where are you from?', countries, default: 'germany')).to eq('germany')
end

it 'ask for a choice and user can reply with initials' do
expects_scan('Where are you from? [ufgi]', 'i')
expect(subject.run('Where are you from?', countries, allow_initials: true)).to eq('italy')
end

it 'keep asking until the answer is valid' do
expects_scan('Where are you from? [usa,france,germany,italy]', 'spain', 'russia', 'france')
expect(subject).to receive(:print_choices_help).twice
expect(subject.run('Where are you from?', countries)).to eq('france')
end
end
end

0 comments on commit 895e299

Please sign in to comment.