From 237aee3f9dce6021147230f6e06b55bd5ed691f2 Mon Sep 17 00:00:00 2001 From: Aaron Allen Date: Fri, 25 Jul 2025 01:54:24 -0500 Subject: [PATCH 01/12] Refactor `example` DSL for better consistency and clarity Simplified the `example` DSL syntax in commands by replacing array-based declarations with clearer method calls. Updated tests and fixtures to align with the new structure, ensuring improved readability and maintainability. --- lib/dry/cli/banner.rb | 111 +++++++++++----- lib/dry/cli/command.rb | 16 +-- spec/integration/inline_spec.rb | 10 +- spec/integration/single_command_spec.rb | 14 +- spec/integration/subcommands_spec.rb | 11 +- spec/support/fixtures/based | 14 +- spec/support/fixtures/foo | 106 ++++++--------- spec/support/fixtures/shared_commands.rb | 123 +++++++----------- spec/support/shared_examples/commands.rb | 17 ++- .../shared_examples/inherited_commands.rb | 46 +++---- spec/support/shared_examples/rendering.rb | 8 +- spec/support/shared_examples/subcommands.rb | 20 ++- spec/unit/dry/cli/cli_spec.rb | 14 +- 13 files changed, 238 insertions(+), 272 deletions(-) diff --git a/lib/dry/cli/banner.rb b/lib/dry/cli/banner.rb index 39e20192..4186b714 100644 --- a/lib/dry/cli/banner.rb +++ b/lib/dry/cli/banner.rb @@ -29,29 +29,71 @@ def self.call(command, name) # @since 1.1.1 # @api private def self.command_banner(command, name) + extended_arguments = extended_command_arguments(command) + extended_examples = extended_command_examples(command, name) + extended_options = extended_command_options(command) + indent = capture_indent(extended_arguments, extended_options, extended_examples) + [ 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) + command_arguments(extended_arguments, indent), + command_options(extended_options, indent), + command_examples(extended_examples, indent) ] end # @since 1.1.1 # @api private def self.namespace_banner(namespace, name) + extended_options = extended_command_options(namespace) + indent = capture_indent([], extended_options, []) + [ command_name(name, "Namespace"), command_name_and_arguments(namespace, name), command_description(namespace), command_subcommands(namespace), - command_options(namespace) + command_options(extended_options, indent) ] end + # @since unreleased + # @api private + def self.capture_indent(extended_arguments, extended_options, extended_examples) + strings = extended_arguments + extended_options + extended_examples + strings.map { |string, _| string.length }.max + 1 + end + + # @since unreleased + # @api private + def self.build_option_right(option) + description = option.desc + unless option.default.nil? + description = "#{description}, default: #{option.default.inspect}" + end + description + end + + # @since unreleased + # @api private + def self.build_option_left(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 + end + # @since 0.1.0 # @api private def self.command_name(name, label = "Command") @@ -80,10 +122,13 @@ def self.command_name_and_arguments(command, name) # @since 0.1.0 # @api private - def self.command_examples(command, name) - return if command.examples.empty? + def self.command_examples(extended_examples, indent) + return if extended_examples.empty? - "\nExamples:\n#{command.examples.map { |example| " #{name} #{example}" }.join("\n")}" + examples = extended_examples.map { |example, description| + " #{example.ljust(indent)} # #{description}" + } + "\nExamples:\n#{examples.join("\n")}" end # @since 0.1.0 @@ -102,16 +147,22 @@ def self.command_subcommands(command) # @since 0.1.0 # @api private - def self.command_arguments(command) - return if command.arguments.empty? + def self.command_arguments(extended_arguments, indent) + return if extended_arguments.empty? - "\nArguments:\n#{extended_command_arguments(command)}" + arguments = extended_arguments.map { |argument, description| + " #{argument.ljust(indent)} # #{description}" + } + "\nArguments:\n#{arguments.join("\n")}" end # @since 0.1.0 # @api private - def self.command_options(command) - "\nOptions:\n#{extended_command_options(command)}" + def self.command_options(extended_options, indent) + options = extended_options.map { |option, description| + " #{option.ljust(indent)} # #{description}" + } + "\nOptions:\n#{options.join("\n")}" end # @since 0.1.0 @@ -131,34 +182,26 @@ def self.arguments(command) # @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") + [argument.name.to_s.upcase, "#{"REQUIRED " if argument.required?}#{argument.desc}"] + end 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.extended_command_examples(command, name) + command.examples.map do |example, description| + ["#{name} #{example}", description] end + end - result << " --#{"help, -h".ljust(30)} # Print this help" - result.join("\n") + # @since 0.1.0 + # @api private + # + def self.extended_command_options(command) + result = command.options.map { |option| + [build_option_left(option), build_option_right(option)] + } + result << ["--help, -h", "Print this help"] end def self.build_subcommands_list(subcommands) diff --git a/lib/dry/cli/command.rb b/lib/dry/cli/command.rb index 84e50d70..842453a4 100644 --- a/lib/dry/cli/command.rb +++ b/lib/dry/cli/command.rb @@ -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(*) # # ... @@ -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 diff --git a/spec/integration/inline_spec.rb b/spec/integration/inline_spec.rb index b00d95fb..d23fc911 100644 --- a/spec/integration/inline_spec.rb +++ b/spec/integration/inline_spec.rb @@ -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 diff --git a/spec/integration/single_command_spec.rb b/spec/integration/single_command_spec.rb index d0a5ba7a..0028dae4 100644 --- a/spec/integration/single_command_spec.rb +++ b/spec/integration/single_command_spec.rb @@ -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 diff --git a/spec/integration/subcommands_spec.rb b/spec/integration/subcommands_spec.rb index aaa3d305..f661f438 100644 --- a/spec/integration/subcommands_spec.rb +++ b/spec/integration/subcommands_spec.rb @@ -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 diff --git a/spec/support/fixtures/based b/spec/support/fixtures/based index 48c9fe4f..f8837fb4 100755 --- a/spec/support/fixtures/based +++ b/spec/support/fixtures/based @@ -38,11 +38,9 @@ module Based option :num, desc: "number of lines to display" option :tail, desc: "continually stream log", type: :boolean - example [ - "APP_NAME", - "APP_NAME --num=50", - "APP_NAME --tail" - ] + example "APP_NAME" + example "APP_NAME --num=50" + example "APP_NAME --tail" end class Addons < Base @@ -50,10 +48,8 @@ module Based option :json, desc: "return add-ons in json format", type: :boolean, default: false - example [ - "APP_NAME", - "APP_NAME --json" - ] + example "APP_NAME" + example "APP_NAME --json" end register "run", Run diff --git a/spec/support/fixtures/foo b/spec/support/fixtures/foo index c7ddb543..805c8b46 100755 --- a/spec/support/fixtures/foo +++ b/spec/support/fixtures/foo @@ -17,9 +17,7 @@ module Foo class Precompile < Dry::CLI::Command desc "Precompile assets for deployment" - example [ - "FOO_ENV=production # Precompile assets for production environment" - ] + example "FOO_ENV=production", "Precompile assets for production environment" def call(*); end end @@ -29,10 +27,8 @@ module Foo desc "Starts Foo console" option :engine, desc: "Force a console engine", values: %w[irb pry ripl] - example [ - " # Uses the bundled engine", - "--engine=pry # Force to use Pry" - ] + example "", "Uses the bundled engine" + example "--engine=pry", "Force to use Pry" def call(engine: nil, **) puts "console - engine: #{engine}" @@ -68,10 +64,8 @@ module Foo desc "Migrate the database" argument :version, desc: "The target version of the migration (see `foo db version`)" - example [ - " # Migrate to the last version", - "20170721120747 # Migrate to a specific version" - ] + example "", "Migrate to the last version" + example "20170721120747", "Migrate to a specific version" def call(*); end end @@ -103,10 +97,8 @@ module Foo class Action < Dry::CLI::Command desc "Destroy an action from app" - example [ - "web home#index # Basic usage", - "admin users#index # Destroy from `admin` app" - ] + example "web home#index", "Basic usage" + example "admin users#index", "Destroy from `admin` app" argument :app, required: true, desc: "The application name (eg. `web`)" argument :action, required: true, desc: "The action name (eg. `home#index`)" @@ -121,9 +113,7 @@ module Foo argument :app, required: true, desc: "The application name (eg. `web`)" - example [ - "admin # Destroy `admin` app" - ] + example "admin", "Destroy `admin` app" def call(*); end end @@ -133,9 +123,7 @@ module Foo argument :mailer, required: true, desc: "The mailer name (eg. `welcome`)" - example [ - "welcome # Destroy `WelcomeMailer` mailer" - ] + example "welcome", "Destroy `WelcomeMailer` mailer" def call(*); end end @@ -145,9 +133,7 @@ module Foo argument :migration, required: true, desc: "The migration name (eg. `create_users`)" - example [ - "create_users # Destroy `db/migrations/20170721120747_create_users.rb`" - ] + example "create_users", "Destroy `db/migrations/20170721120747_create_users.rb`" def call(*); end end @@ -157,9 +143,7 @@ module Foo argument :model, required: true, desc: "The model name (eg. `user`)" - example [ - "user # Destroy `User` entity and `UserRepository` repository" - ] + example "user", "Destroy `User` entity and `UserRepository` repository" def call(*); end end @@ -169,13 +153,11 @@ module Foo class Action < Dry::CLI::Command desc "Generate an action for app" - example [ - "web home#index # Basic usage", - "admin home#index # Generate for `admin` app", - "web home#index --url=/ # Specify URL", - "web sessions#destroy --method=GET # Specify HTTP method", - "web books#create --skip-view # Skip view and template" - ] + example "web home#index", "Basic usage" + example "admin home#index", "Generate for `admin` app" + example "web home#index --url=/", "Specify URL" + example "web sessions#destroy --method=GET", "Specify HTTP method" + example "web books#create --skip-view", "Skip view and template" argument :app, required: true, desc: "The application name (eg. `web`)" argument :action, required: true, desc: "The action name (eg. `home#index`)" @@ -195,10 +177,8 @@ module Foo argument :app, required: true, desc: "The application name (eg. `web`)" option :application_base_url, desc: "The app base URL (eg. `/api/v1`)" - example [ - "admin # Generate `admin` app", - "api --application-base-url=/api/v1 # Generate `api` app and mount at `/api/v1`" - ] + example "admin", "Generate `admin` app" + example "api --application-base-url=/api/v1", "Generate `api` app and mount at `/api/v1`" def call(*) loop { sleep(0.1) } @@ -214,12 +194,10 @@ module Foo option :to, desc: "The default `to` field of the mail" option :subject, desc: "The mail subject" - example [ - "welcome # Basic usage", - 'welcome --from="noreply@example.com" # Generate with default `from` value', - 'announcement --to="users@example.com" # Generate with default `to` value', - 'forgot_password --subject="Your password reset" # Generate with default `subject`' - ] + example "welcome", "Basic usage" + example 'welcome --from="noreply@example.com"', "Generate with default `from` value" + example 'announcement --to="users@example.com"', "Generate with default `to` value" + example 'forgot_password --subject="Your password reset"', "Generate with default `subject`" def call(*); end end @@ -229,9 +207,7 @@ module Foo argument :migration, required: true, desc: "The migration name (eg. `create_users`)" - example [ - "create_users # Generate `db/migrations/20170721120747_create_users.rb`" - ] + example "create_users", "Generate `db/migrations/20170721120747_create_users.rb`" def call(*); end end @@ -242,10 +218,8 @@ module Foo argument :model, required: true, desc: "Model name (eg. `user`)" option :skip_migration, type: :boolean, default: false, desc: "Skip migration" - example [ - "user # Generate `User` entity, `UserRepository` repository, and the migration", - "user --skip-migration # Generate `User` entity and `UserRepository` repository" - ] + example "user", "Generate `User` entity, `UserRepository` repository, and the migration" + example "user --skip-migration", "Generate `User` entity and `UserRepository` repository" def call(model:, **) puts "generate model - model: #{model}" @@ -257,10 +231,8 @@ module Foo argument :app, desc: "The application name (eg. `web`)" - example [ - " # Prints secret (eg. `6fad60e21f3f6bfcaf8e56cdb0f835d644b4892c3badc58328126812429bf073`)", - "web # Prints session secret (eg. `WEB_SESSIONS_SECRET=6fad60e21f3f6bfcaf8e56cdb0f835d644b4892c3badc58328126812429bf073`)" - ] + example "", "Prints secret (eg. `6fad60e21f3f6bfcaf8e56cdb0f835d644b4892c3badc58328126812429bf073`)" + example "web", "Prints session secret (eg. `WEB_SESSIONS_SECRET=6fad60e21f3f6bfcaf8e56cdb0f835d644b4892c3badc58328126812429bf073`)" def call(app: nil, **) puts "generate secret - app: #{app}" @@ -279,13 +251,11 @@ module Foo option :test, desc: "Project testing framework (minitest/rspec)", default: "minitest" option :foo_head, desc: "Use Foo HEAD (true/false)", type: :boolean, default: false - example [ - "bookshelf # Basic usage", - "bookshelf --test=rspec # Setup RSpec testing framework", - "bookshelf --database=postgres # Setup Postgres database", - "bookshelf --template=slim # Setup Slim template engine", - "bookshelf --foo-head # Use Foo HEAD" - ] + example "bookshelf", "Basic usage" + example "bookshelf --test=rspec", "Setup RSpec testing framework" + example "bookshelf --database=postgres", "Setup Postgres database" + example "bookshelf --template=slim", "Setup Slim template engine" + example "bookshelf --foo-head", "Use Foo HEAD" def call(project:, **) puts "new - project: #{project}" @@ -310,13 +280,11 @@ module Foo option :pid, desc: "Path to write a pid file after daemonize" option :code_reloading, desc: "Code reloading", type: :boolean, default: true - 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(options) puts "server - #{options.inspect}" diff --git a/spec/support/fixtures/shared_commands.rb b/spec/support/fixtures/shared_commands.rb index fb4e2743..147e4f49 100644 --- a/spec/support/fixtures/shared_commands.rb +++ b/spec/support/fixtures/shared_commands.rb @@ -8,9 +8,7 @@ module Assets class Precompile < Dry::CLI::Command desc "Precompile assets for deployment" - example [ - "FOO_ENV=production # Precompile assets for production environment" - ] + example "FOO_ENV=production", "Precompile assets for production environment" def call(*); end end @@ -20,10 +18,8 @@ class Console < Dry::CLI::Command desc "Starts Foo console" option :engine, desc: "Force a console engine", values: %w[irb pry ripl] - example [ - " # Uses the bundled engine", - "--engine=pry # Force to use Pry" - ] + example "", "Uses the bundled engine" + example "--engine=pry", "Force to use Pry" def call(engine: nil, **) puts "console - engine: #{engine}" @@ -59,10 +55,8 @@ class Migrate < Dry::CLI::Command desc "Migrate the database" argument :version, desc: "The target version of the migration (see `foo db version`)" - example [ - " # Migrate to the last version", - "20170721120747 # Migrate to a specific version" - ] + example "", "Migrate to the last version" + example "20170721120747", "Migrate to a specific version" def call(*); end end @@ -94,10 +88,8 @@ module Destroy class Action < Dry::CLI::Command desc "Destroy an action from app" - example [ - "web home#index # Basic usage", - "admin users#index # Destroy from `admin` app" - ] + example "web home#index", "Basic usage" + example "admin users#index", "Destroy from `admin` app" argument :app, required: true, desc: "The application name (eg. `web`)" argument :action, required: true, desc: "The action name (eg. `home#index`)" @@ -112,9 +104,7 @@ class App < Dry::CLI::Command argument :app, required: true, desc: "The application name (eg. `web`)" - example [ - "admin # Destroy `admin` app" - ] + example "admin", "Destroy `admin` app" def call(*); end end @@ -124,9 +114,7 @@ class Mailer < Dry::CLI::Command argument :mailer, required: true, desc: "The mailer name (eg. `welcome`)" - example [ - "welcome # Destroy `WelcomeMailer` mailer" - ] + example "welcome", "Destroy `WelcomeMailer` mailer" def call(*); end end @@ -136,9 +124,7 @@ class Migration < Dry::CLI::Command argument :migration, required: true, desc: "The migration name (eg. `create_users`)" - example [ - "create_users # Destroy `db/migrations/20170721120747_create_users.rb`" - ] + example "create_users", "Destroy `db/migrations/20170721120747_create_users.rb`" def call(*); end end @@ -148,9 +134,7 @@ class Model < Dry::CLI::Command argument :model, required: true, desc: "The model name (eg. `user`)" - example [ - "user # Destroy `User` entity and `UserRepository` repository" - ] + example "user", "Destroy `User` entity and `UserRepository` repository" def call(*); end end @@ -160,13 +144,11 @@ module Generate class Action < Dry::CLI::Command desc "Generate an action for app" - example [ - "web home#index # Basic usage", - "admin home#index # Generate for `admin` app", - "web home#index --url=/ # Specify URL", - "web sessions#destroy --method=GET # Specify HTTP method", - "web books#create --skip-view # Skip view and template" - ] + example "web home#index", "Basic usage" + example "admin home#index", "Generate for `admin` app" + example "web home#index --url=/", "Specify URL" + example "web sessions#destroy --method=GET", "Specify HTTP method" + example "web books#create --skip-view", "Skip view and template" argument :app, required: true, desc: "The application name (eg. `web`)" argument :action, required: true, desc: "The action name (eg. `home#index`)" @@ -186,10 +168,8 @@ class App < Dry::CLI::Command argument :app, required: true, desc: "The application name (eg. `web`)" option :application_base_url, desc: "The app base URL (eg. `/api/v1`)" - example [ - "admin # Generate `admin` app", - "api --application-base-url=/api/v1 # Generate `api` app and mount at `/api/v1`" - ] + example "admin", "Generate `admin` app" + example "api --application-base-url=/api/v1", "Generate `api` app and mount at `/api/v1`" def call(*); end end @@ -203,12 +183,10 @@ class Mailer < Dry::CLI::Command option :to, desc: "The default `to` field of the mail" option :subject, desc: "The mail subject" - example [ - "welcome # Basic usage", - 'welcome --from="noreply@example.com" # Generate with default `from` value', - 'announcement --to="users@example.com" # Generate with default `to` value', - 'forgot_password --subject="Your password reset" # Generate with default `subject`' - ] + example "welcome", "Basic usage" + example 'welcome --from="noreply@example.com"', "Generate with default `from` value" + example 'announcement --to="users@example.com"', "Generate with default `to` value" + example 'forgot_password --subject="Your password reset"', "Generate with default `subject`" def call(*); end end @@ -218,9 +196,7 @@ class Migration < Dry::CLI::Command argument :migration, required: true, desc: "The migration name (eg. `create_users`)" - example [ - "create_users # Generate `db/migrations/20170721120747_create_users.rb`" - ] + example "create_users", "Generate `db/migrations/20170721120747_create_users.rb`" def call(*); end end @@ -231,10 +207,8 @@ class Model < Dry::CLI::Command argument :model, required: true, desc: "Model name (eg. `user`)" option :skip_migration, type: :boolean, default: false, desc: "Skip migration" - example [ - "user # Generate `User` entity, `UserRepository` repository, and the migration", - "user --skip-migration # Generate `User` entity and `UserRepository` repository" - ] + example "user", "Generate `User` entity, `UserRepository` repository, and the migration" + example "user --skip-migration", "Generate `User` entity and `UserRepository` repository" def call(model:, **) puts "generate model - model: #{model}" @@ -247,10 +221,8 @@ class Secret < Dry::CLI::Command argument :app, desc: "The application name (eg. `web`)" # rubocop:disable Layout/LineLength - example [ - " # Prints secret (eg. `6fad60e21f3f6bfcaf8e56cdb0f835d644b4892c3badc58328126812429bf073`)", - "web # Prints session secret (eg. `WEB_SESSIONS_SECRET=6fad60e21f3f6bfcaf8e56cdb0f835d644b4892c3badc58328126812429bf073`)" - ] + example "", "Prints secret (eg. `6fad60e21f3f6bfcaf8e56cdb0f835d644b4892c3badc58328126812429bf073`)" + example "web", "Prints session secret (eg. `WEB_SESSIONS_SECRET=6fad60e21f3f6bfcaf8e56cdb0f835d644b4892c3badc58328126812429bf073`)" # rubocop:enable Layout/LineLength def call(app: nil, **) @@ -270,13 +242,11 @@ class New < Dry::CLI::Command option :test, desc: "Project testing framework (minitest/rspec)", default: "minitest" option :foo_head, desc: "Use Foo HEAD (true/false)", type: :boolean, default: false - example [ - "bookshelf # Basic usage", - "bookshelf --test=rspec # Setup RSpec testing framework", - "bookshelf --database=postgres # Setup Postgres database", - "bookshelf --template=slim # Setup Slim template engine", - "bookshelf --foo-head # Use Foo HEAD" - ] + example "bookshelf", "Basic usage" + example "bookshelf --test=rspec", "Setup RSpec testing framework" + example "bookshelf --database=postgres", "Setup Postgres database" + example "bookshelf --template=slim", "Setup Slim template engine" + example "bookshelf --foo-head", "Use Foo HEAD" def call(project:, **) puts "new - project: #{project}" @@ -303,13 +273,11 @@ class Server < Dry::CLI::Command option :quiet, desc: "Suppress output to stdout", type: :flag option :deps, desc: "List of extra dependencies", type: :array, default: %w[dep1 dep2] - 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(**options) puts "server - #{options.inspect}" @@ -561,7 +529,8 @@ class Base < Dry::CLI::Command desc "Base description" argument :app, desc: "Application name", type: :string, required: true option :verbosity, desc: "Verbosity level", type: :string, default: "INFO" - example "Base example" + + example "app_name", "Base example" def call(app:, **options) puts "Base - App: #{app} - Options: #{options.inspect}" @@ -586,11 +555,9 @@ class Logs < Base option :num, desc: "number of lines to display" option :tail, desc: "continually stream log", type: :boolean - example [ - "APP_NAME", - "APP_NAME --num=50", - "APP_NAME --tail" - ] + example "APP_NAME", "Basic usage" + example "APP_NAME --num=50", "Display 50 lines" + example "APP_NAME --tail", "Continually stream log" def call(app:, **options) puts "Logs - App: #{app} - Options: #{options.inspect}" @@ -602,9 +569,7 @@ class Addons < Base option :json, desc: "return add-ons in json format", type: :boolean, default: false - example [ - "APP_NAME", - "APP_NAME --json" - ] + example "APP_NAME", "Basic usage" + example "APP_NAME --json", "Return add-ons in JSON format" end end diff --git a/spec/support/shared_examples/commands.rb b/spec/support/shared_examples/commands.rb index c5339979..02a22d7a 100644 --- a/spec/support/shared_examples/commands.rb +++ b/spec/support/shared_examples/commands.rb @@ -212,13 +212,12 @@ --help, -h # Print this help Examples: - #{cmd} server # Basic usage (it uses the bundled server engine) - #{cmd} server --server=webrick # Force `webrick` server engine - #{cmd} server --host=0.0.0.0 # Bind to a host - #{cmd} server --port=2306 # Bind to a port - #{cmd} server --no-code-reloading # Disable code reloading + #{cmd} server # Basic usage (it uses the bundled server engine) + #{cmd} server --server=webrick # Force `webrick` server engine + #{cmd} server --host=0.0.0.0 # Bind to a host + #{cmd} server --port=2306 # Bind to a port + #{cmd} server --no-code-reloading # Disable code reloading DESC - expect(output).to eq(expected) end @@ -311,11 +310,11 @@ sub-command # Root command sub command Arguments: - ROOT_COMMAND_ARGUMENT # REQUIRED Root command argument + ROOT_COMMAND_ARGUMENT # REQUIRED Root command argument Options: - --root-command-option=VALUE # Root command option - --help, -h # Print this help + --root-command-option=VALUE # Root command option + --help, -h # Print this help DESC expect(output).to eq(expected) diff --git a/spec/support/shared_examples/inherited_commands.rb b/spec/support/shared_examples/inherited_commands.rb index 3e4a6d0f..297659eb 100644 --- a/spec/support/shared_examples/inherited_commands.rb +++ b/spec/support/shared_examples/inherited_commands.rb @@ -43,7 +43,7 @@ sub-command # I'm a concrete command Options: - --help, -h # Print this help + --help, -h # Print this help DESC expect(output).to eq(expected) end @@ -61,12 +61,12 @@ Run a one-off process inside your app Arguments: - APP # REQUIRED Application name - CMD # REQUIRED Command to execute + APP # REQUIRED Application name + CMD # REQUIRED Command to execute Options: - --verbosity=VALUE # Verbosity level, default: "INFO" - --help, -h # Print this help + --verbosity=VALUE # Verbosity level, default: "INFO" + --help, -h # Print this help DESC expect(output).to eq(expected) end @@ -81,12 +81,12 @@ #{cmd} i subrun APP CMD Arguments: - APP # REQUIRED Application name - CMD # REQUIRED Command to execute + APP # REQUIRED Application name + CMD # REQUIRED Command to execute Options: - --verbosity=VALUE # Verbosity level, default: "INFO" - --help, -h # Print this help + --verbosity=VALUE # Verbosity level, default: "INFO" + --help, -h # Print this help DESC expect(output).to eq(expected) end @@ -104,16 +104,16 @@ Lists your add-ons and attachments Arguments: - APP # REQUIRED Application name + APP # REQUIRED Application name Options: - --verbosity=VALUE # Verbosity level, default: "INFO" - --[no-]json # return add-ons in json format, default: false - --help, -h # Print this help + --verbosity=VALUE # Verbosity level, default: "INFO" + --[no-]json # return add-ons in json format, default: false + --help, -h # Print this help Examples: - #{cmd} i addons APP_NAME - #{cmd} i addons APP_NAME --json + #{cmd} i addons APP_NAME # Basic usage + #{cmd} i addons APP_NAME --json # Return add-ons in JSON format DESC expect(output).to eq(expected) end @@ -131,18 +131,18 @@ Display recent log output Arguments: - APP # REQUIRED Application name + APP # REQUIRED Application name Options: - --verbosity=VALUE # Verbosity level, default: "INFO" - --num=VALUE # number of lines to display - --[no-]tail # continually stream log - --help, -h # Print this help + --verbosity=VALUE # Verbosity level, default: "INFO" + --num=VALUE # number of lines to display + --[no-]tail # continually stream log + --help, -h # Print this help Examples: - #{cmd} i logs APP_NAME - #{cmd} i logs APP_NAME --num=50 - #{cmd} i logs APP_NAME --tail + #{cmd} i logs APP_NAME # Basic usage + #{cmd} i logs APP_NAME --num=50 # Display 50 lines + #{cmd} i logs APP_NAME --tail # Continually stream log DESC expect(output).to eq(expected) end diff --git a/spec/support/shared_examples/rendering.rb b/spec/support/shared_examples/rendering.rb index 583ceb58..d0d8de32 100644 --- a/spec/support/shared_examples/rendering.rb +++ b/spec/support/shared_examples/rendering.rb @@ -173,10 +173,10 @@ Accepts options with aliases Options: - --url=VALUE, -u VALUE # The action URL - --[no-]flag, -f # The flag - --[no-]opt, -o # The opt, default: false - --help, -h # Print this help + --url=VALUE, -u VALUE # The action URL + --[no-]flag, -f # The flag + --[no-]opt, -o # The opt, default: false + --help, -h # Print this help DESC expect(output).to eq(expected) diff --git a/spec/support/shared_examples/subcommands.rb b/spec/support/shared_examples/subcommands.rb index 5ece9c6d..fa6d7e56 100644 --- a/spec/support/shared_examples/subcommands.rb +++ b/spec/support/shared_examples/subcommands.rb @@ -85,17 +85,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: - #{cmd} generate model user # Generate `User` entity, `UserRepository` repository, and the migration - #{cmd} generate model user --skip-migration # Generate `User` entity and `UserRepository` repository + #{cmd} generate model user # Generate `User` entity, `UserRepository` repository, and the migration + #{cmd} generate model user --skip-migration # Generate `User` entity and `UserRepository` repository DESC - expect(output).to eq(expected) end @@ -176,22 +175,21 @@ output = capture_output { cli.call(arguments: %w[root-command sub-command -h]) } expected = <<~DESC Command: - rspec root-command sub-command + #{cmd} root-command sub-command Usage: - rspec root-command sub-command ROOT_COMMAND_SUB_COMMAND_ARGUMENT + #{cmd} root-command sub-command ROOT_COMMAND_SUB_COMMAND_ARGUMENT Description: Root command sub command Arguments: - ROOT_COMMAND_SUB_COMMAND_ARGUMENT # REQUIRED Root command sub command argument + ROOT_COMMAND_SUB_COMMAND_ARGUMENT # REQUIRED Root command sub command argument Options: --root-command-sub-command-option=VALUE # Root command sub command option - --help, -h # Print this help + --help, -h # Print this help DESC - expect(output).to eq(expected) output = capture_output { cli.call(arguments: %w[root-command sub-command --help]) } diff --git a/spec/unit/dry/cli/cli_spec.rb b/spec/unit/dry/cli/cli_spec.rb index b48a73ce..4fb2fa69 100644 --- a/spec/unit/dry/cli/cli_spec.rb +++ b/spec/unit/dry/cli/cli_spec.rb @@ -35,23 +35,23 @@ output = capture_output { cli.call(arguments: ["-h"]) } expected_output = <<~OUTPUT Command: - #{cmd} + rspec Usage: - #{cmd} MANDATORY_ARG [OPTIONAL_ARG] + rspec 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 From 7541d724774b47bfb7772a475db2ce0aef148f37 Mon Sep 17 00:00:00 2001 From: Tim Riley Date: Sat, 9 May 2026 17:01:02 +1000 Subject: [PATCH 02/12] Use a Row class for banner rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use this to make the code clearer as we prepare the output and determine indents. Remove all “@since” Yard tags since this is an “@api private” module. Shift some methods further down the module so they’re underneath the other main methods that call them. --- lib/dry/cli/banner.rb | 142 ++++++++++++++++++++---------------------- 1 file changed, 66 insertions(+), 76 deletions(-) diff --git a/lib/dry/cli/banner.rb b/lib/dry/cli/banner.rb index 4186b714..0a0ec906 100644 --- a/lib/dry/cli/banner.rb +++ b/lib/dry/cli/banner.rb @@ -6,15 +6,18 @@ module Dry class CLI # Command banner # - # @since 0.1.0 # @api private module Banner + # A two-column row in the banner: a label and its description. + # + # @api private + Row = Data.define(:label, :description) + # 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) @@ -26,81 +29,44 @@ def self.call(command, name) b.compact.join("\n") end - # @since 1.1.1 # @api private def self.command_banner(command, name) - extended_arguments = extended_command_arguments(command) - extended_examples = extended_command_examples(command, name) - extended_options = extended_command_options(command) - indent = capture_indent(extended_arguments, extended_options, extended_examples) + argument_rows = command_argument_rows(command) + example_rows = command_example_rows(command, name) + option_rows = command_option_rows(command) + indent = capture_indent(argument_rows + option_rows + example_rows) [ command_name(name), command_name_and_arguments(command, name), command_description(command), command_subcommands(command), - command_arguments(extended_arguments, indent), - command_options(extended_options, indent), - command_examples(extended_examples, indent) + command_arguments(argument_rows, indent), + command_options(option_rows, indent), + command_examples(example_rows, indent) ] end # @since 1.1.1 # @api private def self.namespace_banner(namespace, name) - extended_options = extended_command_options(namespace) - indent = capture_indent([], extended_options, []) + option_rows = command_option_rows(namespace) + indent = capture_indent(option_rows) [ command_name(name, "Namespace"), command_name_and_arguments(namespace, name), command_description(namespace), command_subcommands(namespace), - command_options(extended_options, indent) + command_options(option_rows, indent) ] end - # @since unreleased - # @api private - def self.capture_indent(extended_arguments, extended_options, extended_examples) - strings = extended_arguments + extended_options + extended_examples - strings.map { |string, _| string.length }.max + 1 - end - - # @since unreleased - # @api private - def self.build_option_right(option) - description = option.desc - unless option.default.nil? - description = "#{description}, default: #{option.default.inspect}" - end - description - end - - # @since unreleased - # @api private - def self.build_option_left(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 - 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" @@ -120,18 +86,16 @@ def self.command_name_and_arguments(command, name) usage end - # @since 0.1.0 # @api private - def self.command_examples(extended_examples, indent) - return if extended_examples.empty? + def self.command_examples(example_rows, indent) + return if example_rows.empty? - examples = extended_examples.map { |example, description| - " #{example.ljust(indent)} # #{description}" + examples = example_rows.map { |row| + " #{row.label.ljust(indent)} # #{row.description}" } "\nExamples:\n#{examples.join("\n")}" end - # @since 0.1.0 # @api private def self.command_description(command) return if command.description.nil? @@ -145,27 +109,24 @@ def self.command_subcommands(command) "\nSubcommands:\n#{build_subcommands_list(command.subcommands)}" end - # @since 0.1.0 # @api private - def self.command_arguments(extended_arguments, indent) - return if extended_arguments.empty? + def self.command_arguments(argument_rows, indent) + return if argument_rows.empty? - arguments = extended_arguments.map { |argument, description| - " #{argument.ljust(indent)} # #{description}" + arguments = argument_rows.map { |row| + " #{row.label.ljust(indent)} # #{row.description}" } "\nArguments:\n#{arguments.join("\n")}" end - # @since 0.1.0 # @api private - def self.command_options(extended_options, indent) - options = extended_options.map { |option, description| - " #{option.ljust(indent)} # #{description}" + def self.command_options(option_rows, indent) + options = option_rows.map { |row| + " #{row.label.ljust(indent)} # #{row.description}" } "\nOptions:\n#{options.join("\n")}" end - # @since 0.1.0 # @api private def self.arguments(command) args = command.arguments_sorted_by_usage_order @@ -178,30 +139,54 @@ def self.arguments(command) " #{args.join(" ")}" unless args.empty? end - # @since 0.1.0 # @api private - def self.extended_command_arguments(command) + def self.command_argument_rows(command) command.arguments.map do |argument| - [argument.name.to_s.upcase, "#{"REQUIRED " if argument.required?}#{argument.desc}"] + Row.new( + label: argument.name.to_s.upcase, + description: "#{"REQUIRED " if argument.required?}#{argument.desc}" + ) end end - # @since 0.1.0 # @api private - def self.extended_command_examples(command, name) + def self.command_example_rows(command, name) command.examples.map do |example, description| - ["#{name} #{example}", description] + Row.new(label: "#{name} #{example}", description: description) end end - # @since 0.1.0 # @api private - # - def self.extended_command_options(command) + def self.command_option_rows(command) result = command.options.map { |option| - [build_option_left(option), build_option_right(option)] + Row.new(label: build_option_left(option), description: build_option_right(option)) } - result << ["--help, -h", "Print this help"] + result << Row.new(label: "--help, -h", description: "Print this help") + end + + # @api private + def self.build_option_right(option) + description = option.desc + unless option.default.nil? + description = "#{description}, default: #{option.default.inspect}" + end + description + end + + # @api private + def self.build_option_left(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 end def self.build_subcommands_list(subcommands) @@ -209,6 +194,11 @@ def self.build_subcommands_list(subcommands) " #{subcommand_name.ljust(32)} # #{subcommand.command.description}" end.join("\n") end + + # @api private + def self.capture_indent(rows) + rows.map { |row| row.label.length }.max + 1 + end end end end From b7994f3af5d5a34f769e99cbf0281f89adb9974d Mon Sep 17 00:00:00 2001 From: Tim Riley Date: Sat, 9 May 2026 17:02:29 +1000 Subject: [PATCH 03/12] Move row rendering into Row class itself --- lib/dry/cli/banner.rb | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/lib/dry/cli/banner.rb b/lib/dry/cli/banner.rb index 0a0ec906..746c247a 100644 --- a/lib/dry/cli/banner.rb +++ b/lib/dry/cli/banner.rb @@ -11,7 +11,9 @@ module Banner # A two-column row in the banner: a label and its description. # # @api private - Row = Data.define(:label, :description) + Row = Data.define(:label, :description) do + def render(indent) = " #{label.ljust(indent)} # #{description}" + end # Prints command/namespace banner # @@ -90,10 +92,7 @@ def self.command_name_and_arguments(command, name) def self.command_examples(example_rows, indent) return if example_rows.empty? - examples = example_rows.map { |row| - " #{row.label.ljust(indent)} # #{row.description}" - } - "\nExamples:\n#{examples.join("\n")}" + "\nExamples:\n#{example_rows.map { |row| row.render(indent) }.join("\n")}" end # @api private @@ -113,18 +112,12 @@ def self.command_subcommands(command) def self.command_arguments(argument_rows, indent) return if argument_rows.empty? - arguments = argument_rows.map { |row| - " #{row.label.ljust(indent)} # #{row.description}" - } - "\nArguments:\n#{arguments.join("\n")}" + "\nArguments:\n#{argument_rows.map { |row| row.render(indent) }.join("\n")}" end # @api private def self.command_options(option_rows, indent) - options = option_rows.map { |row| - " #{row.label.ljust(indent)} # #{row.description}" - } - "\nOptions:\n#{options.join("\n")}" + "\nOptions:\n#{option_rows.map { |row| row.render(indent) }.join("\n")}" end # @api private From c687b102217bfe2b24350e12c0b1598899ed8f81 Mon Sep 17 00:00:00 2001 From: Tim Riley Date: Sat, 9 May 2026 17:06:48 +1000 Subject: [PATCH 04/12] =?UTF-8?q?Render=20each=20row=20with=20generic=20?= =?UTF-8?q?=E2=80=9Crow=5Fsection=E2=80=9D=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/dry/cli/banner.rb | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/lib/dry/cli/banner.rb b/lib/dry/cli/banner.rb index 746c247a..32911b8f 100644 --- a/lib/dry/cli/banner.rb +++ b/lib/dry/cli/banner.rb @@ -43,9 +43,9 @@ def self.command_banner(command, name) command_name_and_arguments(command, name), command_description(command), command_subcommands(command), - command_arguments(argument_rows, indent), - command_options(option_rows, indent), - command_examples(example_rows, indent) + row_section("Arguments", argument_rows, indent), + row_section("Options", option_rows, indent), + row_section("Examples", example_rows, indent) ] end @@ -60,7 +60,7 @@ def self.namespace_banner(namespace, name) command_name_and_arguments(namespace, name), command_description(namespace), command_subcommands(namespace), - command_options(option_rows, indent) + row_section("Options", option_rows, indent) ] end @@ -89,10 +89,10 @@ def self.command_name_and_arguments(command, name) end # @api private - def self.command_examples(example_rows, indent) - return if example_rows.empty? + def self.row_section(heading, rows, indent) + return if rows.empty? - "\nExamples:\n#{example_rows.map { |row| row.render(indent) }.join("\n")}" + "\n#{heading}:\n#{rows.map { |row| row.render(indent) }.join("\n")}" end # @api private @@ -108,18 +108,6 @@ def self.command_subcommands(command) "\nSubcommands:\n#{build_subcommands_list(command.subcommands)}" end - # @api private - def self.command_arguments(argument_rows, indent) - return if argument_rows.empty? - - "\nArguments:\n#{argument_rows.map { |row| row.render(indent) }.join("\n")}" - end - - # @api private - def self.command_options(option_rows, indent) - "\nOptions:\n#{option_rows.map { |row| row.render(indent) }.join("\n")}" - end - # @api private def self.arguments(command) args = command.arguments_sorted_by_usage_order From deea3a45ae42a496e47869fe6de05f5002ba8d7f Mon Sep 17 00:00:00 2001 From: Tim Riley Date: Sat, 9 May 2026 17:11:06 +1000 Subject: [PATCH 05/12] Simplify command_name_and_arguments --- lib/dry/cli/banner.rb | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/lib/dry/cli/banner.rb b/lib/dry/cli/banner.rb index 32911b8f..6ba3afa2 100644 --- a/lib/dry/cli/banner.rb +++ b/lib/dry/cli/banner.rb @@ -71,21 +71,11 @@ def self.command_name(name, label = "Command") # @api private def self.command_name_and_arguments(command, name) - usage = "\nUsage:\n" + parts = [] + parts << "#{name}#{arguments(command)}" if command.new.respond_to?(:call) + parts << "#{name} SUBCOMMAND" if command.subcommands.any? - callable_root_command = false - if command.new.respond_to?(:call) - callable_root_command = true - usage += " #{name}#{arguments(command)}" - end - - if command.subcommands.any? - usage += " " - usage += "|" if callable_root_command - usage += " #{name} SUBCOMMAND" - end - - usage + "\nUsage:\n #{parts.join(" | ")}" unless parts.empty? end # @api private From fed08f67866fe8146dab6f16d025da982de5d301 Mon Sep 17 00:00:00 2001 From: Tim Riley Date: Sat, 9 May 2026 17:15:28 +1000 Subject: [PATCH 06/12] Simplify method names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mostly drop “build_” as an unneeded prefix. --- lib/dry/cli/banner.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/dry/cli/banner.rb b/lib/dry/cli/banner.rb index 6ba3afa2..c1e331ce 100644 --- a/lib/dry/cli/banner.rb +++ b/lib/dry/cli/banner.rb @@ -95,7 +95,7 @@ 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 # @api private @@ -130,13 +130,13 @@ def self.command_example_rows(command, name) # @api private def self.command_option_rows(command) result = command.options.map { |option| - Row.new(label: build_option_left(option), description: build_option_right(option)) + Row.new(label: option_label(option), description: option_description(option)) } result << Row.new(label: "--help, -h", description: "Print this help") end # @api private - def self.build_option_right(option) + def self.option_description(option) description = option.desc unless option.default.nil? description = "#{description}, default: #{option.default.inspect}" @@ -145,7 +145,7 @@ def self.build_option_right(option) end # @api private - def self.build_option_left(option) + def self.option_label(option) name = Inflector.dasherize(option.name) name = if option.boolean? "--[no-]#{name}" @@ -160,7 +160,7 @@ def self.build_option_left(option) name 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") From f42bd969f167ceb471198832dfccd8c948f281bd Mon Sep 17 00:00:00 2001 From: Tim Riley Date: Sat, 9 May 2026 17:17:08 +1000 Subject: [PATCH 07/12] Simplify option_label --- lib/dry/cli/banner.rb | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/dry/cli/banner.rb b/lib/dry/cli/banner.rb index c1e331ce..937032eb 100644 --- a/lib/dry/cli/banner.rb +++ b/lib/dry/cli/banner.rb @@ -146,18 +146,19 @@ def self.option_description(option) # @api private def self.option_label(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 + 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.subcommands_list(subcommands) From a9d27239d83a12d4a844d140c38b5a208ac9c63b Mon Sep 17 00:00:00 2001 From: Tim Riley Date: Sat, 9 May 2026 17:18:07 +1000 Subject: [PATCH 08/12] Simplify .arguments and avoid deprecation warning --- lib/dry/cli/banner.rb | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/dry/cli/banner.rb b/lib/dry/cli/banner.rb index 937032eb..c34ab5bf 100644 --- a/lib/dry/cli/banner.rb +++ b/lib/dry/cli/banner.rb @@ -100,12 +100,9 @@ def self.command_subcommands(command) # @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! - end + args = command.arguments_sorted_by_usage_order.map { |a| + a.required? ? a.name.to_s.upcase : "[#{a.name.to_s.upcase}]" + } " #{args.join(" ")}" unless args.empty? end From c0922e5e7a90601bc312831b1625c7bb052ade96 Mon Sep 17 00:00:00 2001 From: Tim Riley Date: Sat, 9 May 2026 17:19:38 +1000 Subject: [PATCH 09/12] Clarify .call --- lib/dry/cli/banner.rb | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/dry/cli/banner.rb b/lib/dry/cli/banner.rb index c34ab5bf..73509dff 100644 --- a/lib/dry/cli/banner.rb +++ b/lib/dry/cli/banner.rb @@ -22,13 +22,14 @@ def render(indent) = " #{label.ljust(indent)} # #{description}" # # @api private def self.call(command, name) - b = if CLI.command?(command) - command_banner(command, name) - else - namespace_banner(command, name) - end + banner_lines = + if CLI.command?(command) + command_banner(command, name) + else + namespace_banner(command, name) + end - b.compact.join("\n") + banner_lines.compact.join("\n") end # @api private From 69eac9270171188205476db47a09d644bea03215 Mon Sep 17 00:00:00 2001 From: Tim Riley Date: Sat, 9 May 2026 21:49:27 +1000 Subject: [PATCH 10/12] =?UTF-8?q?Rename=20=E2=80=9Crow=5Fsection=E2=80=9D?= =?UTF-8?q?=20to=20just=20=E2=80=9Csection=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simpler and less confusing --- lib/dry/cli/banner.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/dry/cli/banner.rb b/lib/dry/cli/banner.rb index 73509dff..9ad51787 100644 --- a/lib/dry/cli/banner.rb +++ b/lib/dry/cli/banner.rb @@ -44,9 +44,9 @@ def self.command_banner(command, name) command_name_and_arguments(command, name), command_description(command), command_subcommands(command), - row_section("Arguments", argument_rows, indent), - row_section("Options", option_rows, indent), - row_section("Examples", example_rows, indent) + section("Arguments", argument_rows, indent), + section("Options", option_rows, indent), + section("Examples", example_rows, indent) ] end @@ -61,7 +61,7 @@ def self.namespace_banner(namespace, name) command_name_and_arguments(namespace, name), command_description(namespace), command_subcommands(namespace), - row_section("Options", option_rows, indent) + section("Options", option_rows, indent) ] end @@ -80,7 +80,7 @@ def self.command_name_and_arguments(command, name) end # @api private - def self.row_section(heading, rows, indent) + def self.section(heading, rows, indent) return if rows.empty? "\n#{heading}:\n#{rows.map { |row| row.render(indent) }.join("\n")}" From f07764abc0881a96ef902fbc38390c47d878e5dc Mon Sep 17 00:00:00 2001 From: Tim Riley Date: Sat, 9 May 2026 22:11:08 +1000 Subject: [PATCH 11/12] Rename Row to Entry This feels more natural --- lib/dry/cli/banner.rb | 48 +++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/lib/dry/cli/banner.rb b/lib/dry/cli/banner.rb index 9ad51787..47d912aa 100644 --- a/lib/dry/cli/banner.rb +++ b/lib/dry/cli/banner.rb @@ -8,10 +8,10 @@ class CLI # # @api private module Banner - # A two-column row in the banner: a label and its description. + # An entry in a banner section: a label and its description. # # @api private - Row = Data.define(:label, :description) do + Entry = Data.define(:label, :description) do def render(indent) = " #{label.ljust(indent)} # #{description}" end @@ -34,34 +34,34 @@ def self.call(command, name) # @api private def self.command_banner(command, name) - argument_rows = command_argument_rows(command) - example_rows = command_example_rows(command, name) - option_rows = command_option_rows(command) - indent = capture_indent(argument_rows + option_rows + example_rows) + 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), - section("Arguments", argument_rows, indent), - section("Options", option_rows, indent), - section("Examples", example_rows, indent) + 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_rows = command_option_rows(namespace) - indent = capture_indent(option_rows) + 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), - section("Options", option_rows, indent) + section("Options", option_entries, indent) ] end @@ -80,10 +80,10 @@ def self.command_name_and_arguments(command, name) end # @api private - def self.section(heading, rows, indent) - return if rows.empty? + def self.section(heading, entries, indent) + return if entries.empty? - "\n#{heading}:\n#{rows.map { |row| row.render(indent) }.join("\n")}" + "\n#{heading}:\n#{entries.map { |entry| entry.render(indent) }.join("\n")}" end # @api private @@ -109,9 +109,9 @@ def self.arguments(command) end # @api private - def self.command_argument_rows(command) + def self.command_argument_entries(command) command.arguments.map do |argument| - Row.new( + Entry.new( label: argument.name.to_s.upcase, description: "#{"REQUIRED " if argument.required?}#{argument.desc}" ) @@ -119,18 +119,18 @@ def self.command_argument_rows(command) end # @api private - def self.command_example_rows(command, name) + def self.command_example_entries(command, name) command.examples.map do |example, description| - Row.new(label: "#{name} #{example}", description: description) + Entry.new(label: "#{name} #{example}", description: description) end end # @api private - def self.command_option_rows(command) + def self.command_option_entries(command) result = command.options.map { |option| - Row.new(label: option_label(option), description: option_description(option)) + Entry.new(label: option_label(option), description: option_description(option)) } - result << Row.new(label: "--help, -h", description: "Print this help") + result << Entry.new(label: "--help, -h", description: "Print this help") end # @api private @@ -166,8 +166,8 @@ def self.subcommands_list(subcommands) end # @api private - def self.capture_indent(rows) - rows.map { |row| row.label.length }.max + 1 + def self.capture_indent(entries) + entries.map { |entry| entry.label.length }.max + 1 end end end From e60e01751bb8389d6a5f1706665bb66e62979ec0 Mon Sep 17 00:00:00 2001 From: Tim Riley Date: Wed, 20 May 2026 22:09:12 +1000 Subject: [PATCH 12/12] Update CHANGELOG --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 558fe5ca..f0013f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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