Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement option required: true #122

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@

This library officially supports the following Ruby versions:

* MRI `>= 2.4.0`
* jruby `>= 9.4` (not tested on CI)
* MRI `>= 2.7.0`
* jruby `>= 9.3` (postponed until 2.7 is supported)

## License

Expand Down
37 changes: 24 additions & 13 deletions lib/dry/cli/banner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,24 +101,35 @@ def self.extended_command_arguments(command)
end.join("\n")
end

# @since x.x.x
# @api private
def self.simple_option(option)
name = Inflector.dasherize(option.name)
name = if option.boolean?
"[no-]#{name}"
elsif option.array?
"#{name}=VALUE1,VALUE2,.."
else
"#{name}=VALUE"
end
name = "#{name}, #{option.alias_names.join(", ")}" if option.aliases.any?
"--#{name}"
end

# @since x.x.x
# @api private
def self.extended_option(option)
name = " #{simple_option(option).ljust(32)} # #{"REQUIRED " if option.required?}#{option.desc}" # rubocop:disable Metrics/LineLength
name = "#{name}, default: #{option.default.inspect}" unless option.default.nil?
name
end

# @since 0.1.0
# @api private
#
def self.extended_command_options(command)
result = command.options.map do |option|
name = Inflector.dasherize(option.name)
name = if option.boolean?
"[no-]#{name}"
elsif option.array?
"#{name}=VALUE1,VALUE2,.."
else
"#{name}=VALUE"
end
name = "#{name}, #{option.alias_names.join(", ")}" if option.aliases.any?
name = " --#{name.ljust(30)}"
name = "#{name} # #{option.desc}"
name = "#{name}, default: #{option.default.inspect}" unless option.default.nil?
name
extended_option(option)
end

result << " --#{"help, -h".ljust(30)} # Print this help"
Expand Down
16 changes: 11 additions & 5 deletions lib/dry/cli/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,10 @@ module ClassMethods

# @since 0.7.0
# @api private
attr_reader :subcommands
attr_accessor :subcommands

# @since 0.7.0
# @api private
attr_writer :subcommands
end

# Set the description of the command
Expand Down Expand Up @@ -344,6 +343,12 @@ def self.optional_arguments
arguments.reject(&:required?)
end

# @since x.x.x
# @api private
def self.required_options
options.select(&:required?)
end

# @since 0.7.0
# @api private
def self.subcommands
Expand Down Expand Up @@ -373,14 +378,15 @@ def self.superclass_options
extend Forwardable

delegate %i[
arguments
default_params
description
examples
arguments
optional_arguments
options
params
default_params
required_arguments
optional_arguments
required_options
subcommands
] => "self.class"
end
Expand Down
65 changes: 46 additions & 19 deletions lib/dry/cli/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,52 +29,79 @@ def self.call(command, arguments, prog_name)
end
end.parse!(arguments)

parsed_options = command.default_params.merge(parsed_options)
parse_required_params(command, arguments, prog_name, parsed_options)
rescue ::OptionParser::ParseError
Result.failure("ERROR: \"#{prog_name}\" was called with arguments \"#{original_arguments.join(" ")}\"") # rubocop:disable Metrics/LineLength
Result.failure("ERROR: \"#{prog_name}\" was called with arguments \"#{original_arguments.join(" ")}\"") # rubocop:disable Layout/LineLength
end

# @since 0.1.0
# @api private
#
# rubocop:disable Metrics/AbcSize
def self.parse_required_params(command, arguments, prog_name, parsed_options)
parsed_params = match_arguments(command.arguments, arguments)
parsed_params = match_arguments(command.arguments, arguments)
parsed_required_params = match_arguments(command.required_arguments, arguments)
all_required_params_satisfied = command.required_arguments.all? { |param| !parsed_required_params[param.name].nil? } # rubocop:disable Metrics/LineLength
parsed_options_with_defaults = command.default_params.merge(parsed_options)

all_required_params_satisfied =
command.required_arguments.all? { |param| !parsed_required_params[param.name].nil? } &&
command.required_options.all? { |option| !parsed_options_with_defaults[option.name].nil? }

unused_arguments = arguments.drop(command.required_arguments.length)

unless all_required_params_satisfied
parsed_required_params_values = parsed_required_params.values.compact

usage = "\nUsage: \"#{prog_name} #{command.required_arguments.map(&:description_name).join(" ")}" # rubocop:disable Metrics/LineLength

usage += " | #{prog_name} SUBCOMMAND" if command.subcommands.any?

usage += '"'

if parsed_required_params_values.empty?
return Result.failure("ERROR: \"#{prog_name}\" was called with no arguments#{usage}")
else
return Result.failure("ERROR: \"#{prog_name}\" was called with arguments #{parsed_required_params_values}#{usage}") # rubocop:disable Metrics/LineLength
end
return error_message(
command, prog_name, parsed_required_params, parsed_options, parsed_options_with_defaults
)
end

parsed_params.reject! { |_key, value| value.nil? }
parsed_options = parsed_options.merge(parsed_params)
parsed_options = parsed_options_with_defaults.merge(parsed_params)
parsed_options = parsed_options.merge(args: unused_arguments) if unused_arguments.any?
Result.success(parsed_options)
end
# rubocop:enable Metrics/AbcSize

def self.short_usage(command, prog_name)
usage = "\nUsage: \"#{prog_name} #{command.required_arguments.map(&:description_name).join(" ")}" # rubocop:disable Layout/LineLength
usage += " | #{prog_name} SUBCOMMAND" if command.subcommands.any?
usage += " #{command.required_options.map { |opt| Banner.simple_option(opt) }.join(" ")}" if command.required_options.any? # rubocop:disable Layout/LineLength
usage += '"'
usage
end

def self.error_message(command, prog_name, parsed_required_params, parsed_options, parsed_options_with_defaults)
parsed_required_params_values = parsed_required_params.values.compact

missing_options = command.required_options.select { |option|
parsed_options_with_defaults[option.name].nil?
}

error_msg = "ERROR: \"#{prog_name}\" was called with "
error_msg += if parsed_required_params_values.empty?
"no arguments"
else
"arguments #{parsed_required_params_values}"
end
error_msg += " and options #{parsed_options}" if parsed_options.any?
error_msg += short_usage(command, prog_name)

if missing_options.any?
error_msg += "\nMissing required options:"
missing_options.each do |missing_option|
error_msg += "\n #{Banner.extended_option(missing_option)}"
end
end

Result.failure(error_msg)
end

def self.match_arguments(command_arguments, arguments)
result = {}

command_arguments.each_with_index do |cmd_arg, index|
if cmd_arg.array?
result[cmd_arg.name] = arguments[index..-1]
result[cmd_arg.name] = arguments[index..]
break
else
result[cmd_arg.name] = arguments.at(index)
Expand Down
96 changes: 69 additions & 27 deletions spec/integration/single_command_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
it "shows usage" do
_, stderr, = Open3.capture3("baz")
expect(stderr).to eq(
"ERROR: \"#{cmd}\" was called with no arguments\nUsage: \"#{cmd} MANDATORY_ARG\"\n"
"ERROR: \"#{cmd}\" was called with no arguments\n"\
"Usage: \"#{cmd} MANDATORY_ARG --mandatory-option=VALUE --mandatory-option-with-default=VALUE\"\n"\
"Missing required options:\n --mandatory-option=VALUE # REQUIRED Mandatory option\n"
)
end

Expand All @@ -33,49 +35,89 @@
--option-one=VALUE, -1 VALUE # Option one
--[no-]boolean-option, -b # Option boolean
--option-with-default=VALUE, -d VALUE # Option default, default: "test"
--mandatory-option=VALUE # REQUIRED Mandatory option
--mandatory-option-with-default=VALUE # REQUIRED Mandatory option, default: "mandatory default"
--help, -h # Print this help
OUTPUT
expect(output).to eq(expected_output)
end

it "with option_one" do
output = `baz first_arg --option-one=test2`
expect(output).to eq(
"mandatory_arg: first_arg. optional_arg: optional_arg. " \
"Options: {:option_with_default=>\"test\", :option_one=>\"test2\"}\n"
)
context "with mandatory arg and non-required option" do
it "errors out and shows usage" do
_, stderr, = Open3.capture3("baz first_arg --option_one=test2")
expect(stderr).to eq(
"ERROR: \"#{cmd}\" was called with arguments [\"first_arg\"] and options {:option_one=>\"test2\"}\n" \
"Usage: \"#{cmd} MANDATORY_ARG --mandatory-option=VALUE --mandatory-option-with-default=VALUE\"\n"\
"Missing required options:\n --mandatory-option=VALUE # REQUIRED Mandatory option\n"
)
end
end

it "with combination of aliases" do
output = `baz first_arg -bd test3`
expect(output).to eq(
"mandatory_arg: first_arg. optional_arg: optional_arg. " \
"Options: {:option_with_default=>\"test3\", :boolean_option=>true}\n"
)
context "with mandatory arg and mandatory_option" do
it "works" do
output = `baz first_arg --mandatory-option=test1`
expect(output).to eq(
"mandatory_arg: first_arg. optional_arg: optional_arg. " \
"mandatory_option: test1. " \
"Options: {:option_with_default=>\"test\", " \
":mandatory_option_with_default=>\"mandatory default\", " \
":mandatory_option=>\"test1\"}\n"
)
end
end

context "with mandatory arg, option_one and mandatory_option" do
it "works" do
output = `baz first_arg --mandatory-option=test1 --option_one=test2`
expect(output).to eq(
"mandatory_arg: first_arg. optional_arg: optional_arg. " \
"mandatory_option: test1. " \
"Options: {:option_with_default=>\"test\", " \
":mandatory_option_with_default=>\"mandatory default\", " \
":mandatory_option=>\"test1\", :option_one=>\"test2\"}\n"
)
end
end

context "with combination of aliases" do
it "works" do
output = `baz first_arg --mandatory-option test1 -bd test3`
expect(output).to eq(
"mandatory_arg: first_arg. optional_arg: optional_arg. " \
"mandatory_option: test1. " \
"Options: {:option_with_default=>\"test3\", " \
":mandatory_option_with_default=>\"mandatory default\", " \
":mandatory_option=>\"test1\", :boolean_option=>true}\n"
)
end
end
end

context "root command with arguments and subcommands" do
it "with arguments" do
output = `foo root-command "hello world"`
context "with arguments" do
it "works" do
output = `foo root-command "hello world"`

expected = <<~DESC
I'm a root-command argument:hello world
I'm a root-command option:
DESC
expected = <<~DESC
I'm a root-command argument:hello world
I'm a root-command option:
DESC

expect(output).to eq(expected)
expect(output).to eq(expected)
end
end

it "with options" do
output = `foo root-command "hello world" --root-command-option="bye world"`
context "with options" do
it "works" do
output = `foo root-command "hello world" --root-command-option="bye world"`

expected = <<~DESC
I'm a root-command argument:hello world
I'm a root-command option:bye world
DESC
expected = <<~DESC
I'm a root-command argument:hello world
I'm a root-command option:bye world
DESC

expect(output).to eq(expected)
expect(output).to eq(expected)
end
end
end
end
3 changes: 3 additions & 0 deletions spec/support/fixtures/baz_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ class CLI < Dry::CLI::Command
option :option_one, aliases: %w[1], desc: "Option one"
option :boolean_option, aliases: %w[b], desc: "Option boolean", type: :boolean
option :option_with_default, aliases: %w[d], desc: "Option default", default: "test"
option :mandatory_option, desc: "Mandatory option", required: true
option :mandatory_option_with_default, desc: "Mandatory option", required: true, default: "mandatory default"

def call(mandatory_arg:, optional_arg: "optional_arg", **options)
puts "mandatory_arg: #{mandatory_arg}. " \
"optional_arg: #{optional_arg}. " \
"mandatory_option: #{options[:mandatory_option]}. "\
"Options: #{options.inspect}"
end
end
Expand Down
6 changes: 2 additions & 4 deletions spec/support/shared_examples/commands.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# frozen_string_literal: true

# rubocop:disable Metrics/LineLength
RSpec.shared_examples "Commands" do |cli|
let(:cli) { cli }

Expand Down Expand Up @@ -96,7 +95,7 @@
context "and with an unknown value passed" do
it "prints error" do
error = capture_error { cli.call(arguments: %w[console --engine=unknown]) }
expect(error).to eq("ERROR: \"rspec console\" was called with arguments \"--engine=unknown\"\n") # rubocop:disable Metrics/LineLength
expect(error).to eq("ERROR: \"rspec console\" was called with arguments \"--engine=unknown\"\n")
end
end
end
Expand Down Expand Up @@ -145,7 +144,7 @@

it "with unknown param" do
error = capture_error { cli.call(arguments: %w[new bookshelf --unknown 1234]) }
expect(error).to eq("ERROR: \"rspec new\" was called with arguments \"bookshelf --unknown 1234\"\n") # rubocop:disable Metrics/LineLength
expect(error).to eq("ERROR: \"rspec new\" was called with arguments \"bookshelf --unknown 1234\"\n")
end

it "no required" do
Expand Down Expand Up @@ -297,4 +296,3 @@
end
end
end
# rubocop:enable Metrics/LineLength
10 changes: 5 additions & 5 deletions spec/support/shared_examples/subcommands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,17 @@
end

it "more than one param and with optional params" do
output = capture_output { cli.call(arguments: %w[generate action web users#index --url=/signin]) } # rubocop:disable Metrics/LineLength
expect(output).to eq("generate action - app: web, action: users#index, options: {:skip_view=>false, :url=>\"/signin\"}\n") # rubocop:disable Metrics/LineLength
output = capture_output { cli.call(arguments: %w[generate action web users#index --url=/signin]) }
expect(output).to eq("generate action - app: web, action: users#index, options: {:skip_view=>false, :url=>\"/signin\"}\n")
end

it "more than one param and with boolean params" do
output = capture_output { cli.call(arguments: %w[generate action web users#index --skip-view --url=/signin]) } # rubocop:disable Metrics/LineLength
expect(output).to eq("generate action - app: web, action: users#index, options: {:skip_view=>true, :url=>\"/signin\"}\n") # rubocop:disable Metrics/LineLength
output = capture_output { cli.call(arguments: %w[generate action web users#index --skip-view --url=/signin]) }
expect(output).to eq("generate action - app: web, action: users#index, options: {:skip_view=>true, :url=>\"/signin\"}\n")
end

it "more than required params" do
output = capture_output { cli.call(arguments: %w[destroy action web users#index unexpected_param]) } # rubocop:disable Metrics/LineLength
output = capture_output { cli.call(arguments: %w[destroy action web users#index unexpected_param]) }
expect(output).to eq("destroy action - app: web, action: users#index\n")
end

Expand Down
Loading