Skip to content
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve

### Changed

- The `example` DSL now takes the example and its description as separate arguments, called once per example. Previously, examples were passed as a single array of strings with the description embedded after a `#`. (@aaronmallen and @timriley in #152)

```ruby
class Server < Dry::CLI::Command
# Now:
example "--server=webrick", "Force `webrick` server engine"
example "--host=0.0.0.0", "Bind to a host"
example "--port=2306", "Bind to a port"
example "--no-code-reloading", "Disable code reloading"

# Before:
example [
"--server=webrick # Force `webrick` server engine",
"--host=0.0.0.0 # Bind to a host",
# ...
]
end

### Deprecated

### Removed
Expand Down
161 changes: 82 additions & 79 deletions lib/dry/cli/banner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,87 +6,86 @@ module Dry
class CLI
# Command banner
#
# @since 0.1.0
# @api private
module Banner
# An entry in a banner section: a label and its description.
#
# @api private
Entry = Data.define(:label, :description) do
def render(indent) = " #{label.ljust(indent)} # #{description}"
end

# Prints command/namespace banner
#
# @param command [Dry::CLI::Command, Dry::CLI::Namespace] the command/namespace
# @param out [IO] standard output
#
# @since 0.1.0
# @api private
def self.call(command, name)
b = if CLI.command?(command)
command_banner(command, name)
else
namespace_banner(command, name)
end

b.compact.join("\n")
banner_lines =
if CLI.command?(command)
command_banner(command, name)
else
namespace_banner(command, name)
end

banner_lines.compact.join("\n")
end

# @since 1.1.1
# @api private
def self.command_banner(command, name)
argument_entries = command_argument_entries(command)
example_entries = command_example_entries(command, name)
option_entries = command_option_entries(command)
indent = capture_indent(argument_entries + option_entries + example_entries)

[
command_name(name),
command_name_and_arguments(command, name),
command_description(command),
command_subcommands(command),
command_arguments(command),
command_options(command),
command_examples(command, name)
section("Arguments", argument_entries, indent),
section("Options", option_entries, indent),
section("Examples", example_entries, indent)
]
end

# @since 1.1.1
# @api private
def self.namespace_banner(namespace, name)
option_entries = command_option_entries(namespace)
indent = capture_indent(option_entries)

[
command_name(name, "Namespace"),
command_name_and_arguments(namespace, name),
command_description(namespace),
command_subcommands(namespace),
command_options(namespace)
section("Options", option_entries, indent)
]
end

# @since 0.1.0
# @api private
def self.command_name(name, label = "Command")
"#{label}:\n #{name}"
end

# @since 0.1.0
# @api private
def self.command_name_and_arguments(command, name)
usage = "\nUsage:\n"

callable_root_command = false
if command.new.respond_to?(:call)
callable_root_command = true
usage += " #{name}#{arguments(command)}"
end
parts = []
parts << "#{name}#{arguments(command)}" if command.new.respond_to?(:call)
parts << "#{name} SUBCOMMAND" if command.subcommands.any?

if command.subcommands.any?
usage += " "
usage += "|" if callable_root_command
usage += " #{name} SUBCOMMAND"
end

usage
"\nUsage:\n #{parts.join(" | ")}" unless parts.empty?
end

# @since 0.1.0
# @api private
def self.command_examples(command, name)
return if command.examples.empty?
def self.section(heading, entries, indent)
return if entries.empty?

"\nExamples:\n#{command.examples.map { |example| " #{name} #{example}" }.join("\n")}"
"\n#{heading}:\n#{entries.map { |entry| entry.render(indent) }.join("\n")}"
end

# @since 0.1.0
# @api private
def self.command_description(command)
return if command.description.nil?
Expand All @@ -97,75 +96,79 @@ def self.command_description(command)
def self.command_subcommands(command)
return if command.subcommands.empty?

"\nSubcommands:\n#{build_subcommands_list(command.subcommands)}"
"\nSubcommands:\n#{subcommands_list(command.subcommands)}"
end

# @since 0.1.0
# @api private
def self.command_arguments(command)
return if command.arguments.empty?
def self.arguments(command)
args = command.arguments_sorted_by_usage_order.map { |a|
a.required? ? a.name.to_s.upcase : "[#{a.name.to_s.upcase}]"
}

"\nArguments:\n#{extended_command_arguments(command)}"
" #{args.join(" ")}" unless args.empty?
end

# @since 0.1.0
# @api private
def self.command_options(command)
"\nOptions:\n#{extended_command_options(command)}"
def self.command_argument_entries(command)
command.arguments.map do |argument|
Entry.new(
label: argument.name.to_s.upcase,
description: "#{"REQUIRED " if argument.required?}#{argument.desc}"
)
end
end

# @since 0.1.0
# @api private
def self.arguments(command)
args = command.arguments_sorted_by_usage_order
args.map! do |a|
# a.to_s raises deprecation warning that it will result in a frozen string in the future
name = a.required? ? "#{a.name}" : "[#{a.name}]" # rubocop:disable Style/RedundantInterpolation
name.upcase!
def self.command_example_entries(command, name)
command.examples.map do |example, description|
Entry.new(label: "#{name} #{example}", description: description)
end

" #{args.join(" ")}" unless args.empty?
end

# @since 0.1.0
# @api private
def self.extended_command_arguments(command)
command.arguments.map do |argument|
" #{argument.name.to_s.upcase.ljust(32)} # #{"REQUIRED " if argument.required?}#{argument.desc}"
end.join("\n")
def self.command_option_entries(command)
result = command.options.map { |option|
Entry.new(label: option_label(option), description: option_description(option))
}
result << Entry.new(label: "--help, -h", description: "Print this help")
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.flag?
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
def self.option_description(option)
description = option.desc
unless option.default.nil?
description = "#{description}, default: #{option.default.inspect}"
end
description
end

result << " --#{"help, -h".ljust(30)} # Print this help"
result.join("\n")
# @api private
def self.option_label(option)
base = Inflector.dasherize(option.name)
label =
if option.boolean?
"--[no-]#{base}"
elsif option.flag?
"--#{base}"
elsif option.array?
"--#{base}=VALUE1,VALUE2,.."
else
"--#{base}=VALUE"
end
label = "#{label}, #{option.alias_names.join(", ")}" if option.aliases.any?
label
end

def self.build_subcommands_list(subcommands)
def self.subcommands_list(subcommands)
subcommands.map do |subcommand_name, subcommand|
" #{subcommand_name.ljust(32)} # #{subcommand.command.description}"
end.join("\n")
end

# @api private
def self.capture_indent(entries)
entries.map { |entry| entry.label.length }.max + 1
end
end
end
end
16 changes: 7 additions & 9 deletions lib/dry/cli/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,11 @@ def self.desc(description)
# require "dry/cli"
#
# class Server < Dry::CLI::Command
# example [
# " # Basic usage (it uses the bundled server engine)",
# "--server=webrick # Force `webrick` server engine",
# "--host=0.0.0.0 # Bind to a host",
# "--port=2306 # Bind to a port",
# "--no-code-reloading # Disable code reloading"
# ]
# example "", "Basic usage (it uses the bundled server engine)",
# example "--server=webrick, "Force `webrick` server engine",
# example "--host=0.0.0.0, "Bind to a host",
# example "--port=2306", "Bind to a port",
# example "--no-code-reloading", "Disable code reloading"
#
# def call(*)
# # ...
Expand All @@ -100,8 +98,8 @@ def self.desc(description)
# # foo server --host=0.0.0.0 # Bind to a host
# # foo server --port=2306 # Bind to a port
# # foo server --no-code-reloading # Disable code reloading
def self.example(*examples)
@examples += examples.flatten(1)
def self.example(example, description = "")
@examples.push([example, description])
end

# Specify an argument
Expand Down
10 changes: 5 additions & 5 deletions spec/integration/inline_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@
Baz command line interface

Arguments:
MANDATORY_ARG # REQUIRED Mandatory argument
OPTIONAL_ARG # Optional argument (has to have default value in call method)
MANDATORY_ARG # REQUIRED Mandatory argument
OPTIONAL_ARG # Optional argument (has to have default value in call method)

Options:
--option-one=VALUE, -1 VALUE # Option one
--[no-]boolean-option, -b # Option boolean
--option-one=VALUE, -1 VALUE # Option one
--[no-]boolean-option, -b # Option boolean
--option-with-default=VALUE, -d VALUE # Option default, default: "test"
--help, -h # Print this help
--help, -h # Print this help
OUTPUT
expect(output).to eq(expected_output)
end
Expand Down
14 changes: 7 additions & 7 deletions spec/integration/single_command_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,23 @@
output = `baz -h`
expected_output = <<~OUTPUT
Command:
#{cmd}
baz

Usage:
#{cmd} MANDATORY_ARG [OPTIONAL_ARG]
baz MANDATORY_ARG [OPTIONAL_ARG]

Description:
Baz command line interface

Arguments:
MANDATORY_ARG # REQUIRED Mandatory argument
OPTIONAL_ARG # Optional argument (has to have default value in call method)
MANDATORY_ARG # REQUIRED Mandatory argument
OPTIONAL_ARG # Optional argument (has to have default value in call method)

Options:
--option-one=VALUE, -1 VALUE # Option one
--[no-]boolean-option, -b # Option boolean
--option-one=VALUE, -1 VALUE # Option one
--[no-]boolean-option, -b # Option boolean
--option-with-default=VALUE, -d VALUE # Option default, default: "test"
--help, -h # Print this help
--help, -h # Print this help
OUTPUT
expect(output).to eq(expected_output)
end
Expand Down
11 changes: 5 additions & 6 deletions spec/integration/subcommands_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,16 @@
Generate a model

Arguments:
MODEL # REQUIRED Model name (eg. `user`)
MODEL # REQUIRED Model name (eg. `user`)

Options:
--[no-]skip-migration # Skip migration, default: false
--help, -h # Print this help
--[no-]skip-migration # Skip migration, default: false
--help, -h # Print this help

Examples:
foo generate model user # Generate `User` entity, `UserRepository` repository, and the migration
foo generate model user --skip-migration # Generate `User` entity and `UserRepository` repository
foo generate model user # Generate `User` entity, `UserRepository` repository, and the migration
foo generate model user --skip-migration # Generate `User` entity and `UserRepository` repository
DESC

expect(output).to eq(expected)
end
end
Expand Down
Loading