From 8b8567fbc3a654fa7d96cb0797b016ebabc05101 Mon Sep 17 00:00:00 2001 From: Gustavo Ribeiro Date: Tue, 23 Jul 2024 20:33:15 -0300 Subject: [PATCH 1/4] Properly display command usage when it has sub-commands and doesn't have arguments --- lib/dry/cli/usage.rb | 19 ++++++++++--------- spec/support/fixtures/shared_commands.rb | 12 ++++++++++++ spec/support/fixtures/with_block.rb | 3 +++ spec/support/fixtures/with_registry.rb | 2 ++ .../support/fixtures/with_zero_arity_block.rb | 4 ++++ spec/support/shared_examples/rendering.rb | 3 +++ 6 files changed, 34 insertions(+), 9 deletions(-) diff --git a/lib/dry/cli/usage.rb b/lib/dry/cli/usage.rb index 402d6bc..e19a0ba 100644 --- a/lib/dry/cli/usage.rb +++ b/lib/dry/cli/usage.rb @@ -31,15 +31,16 @@ def self.call(result) def self.commands_and_arguments(result) max_length = 0 ret = commands(result).each_with_object({}) do |(name, node), memo| - args = if node.command && node.leaf? && node.children? - ROOT_COMMAND_WITH_SUBCOMMANDS_BANNER - elsif node.leaf? - arguments(node.command) - else - SUBCOMMAND_BANNER - end - - partial = " #{command_name(result, name)}#{args}" + args = arguments(node.command) + args_banner = if node.command && node.leaf? && node.children? && args + ROOT_COMMAND_WITH_SUBCOMMANDS_BANNER + elsif node.leaf? && args + args + elsif node.children? + SUBCOMMAND_BANNER + end + + partial = " #{command_name(result, name)}#{args_banner}" max_length = partial.bytesize if max_length < partial.bytesize memo[partial] = node end diff --git a/spec/support/fixtures/shared_commands.rb b/spec/support/fixtures/shared_commands.rb index f694cd9..3cde0f8 100644 --- a/spec/support/fixtures/shared_commands.rb +++ b/spec/support/fixtures/shared_commands.rb @@ -438,6 +438,18 @@ def call(**params) end end + class Namespace < Dry::CLI::Command + desc "This is a namespace" + + class SubCommand < Dry::CLI::Command + desc "I'm a concrete command" + + def call(**params) + puts "I'm a concrete command" + end + end + end + class InitializedCommand < Dry::CLI::Command attr_reader :prop diff --git a/spec/support/fixtures/with_block.rb b/spec/support/fixtures/with_block.rb index 04bae70..c7427e1 100755 --- a/spec/support/fixtures/with_block.rb +++ b/spec/support/fixtures/with_block.rb @@ -22,6 +22,9 @@ cli.register "root-command", Commands::RootCommand do |prefix| prefix.register "sub-command", Commands::RootCommands::SubCommand end + cli.register "namespace", Commands::Namespace do |prefix| + prefix.register "sub-command", Commands::Namespace::SubCommand + end cli.register "options-with-aliases", Commands::OptionsWithAliases cli.register "variadic default", Commands::VariadicArguments diff --git a/spec/support/fixtures/with_registry.rb b/spec/support/fixtures/with_registry.rb index 9aac805..098fb61 100644 --- a/spec/support/fixtures/with_registry.rb +++ b/spec/support/fixtures/with_registry.rb @@ -58,6 +58,8 @@ module Commands register "with-initializer", ::Commands::InitializedCommand.new(prop: "prop_val") register "root-command", ::Commands::RootCommand register "root-command sub-command", ::Commands::RootCommands::SubCommand + register "namespace", ::Commands::Namespace + register "namespace sub-command", ::Commands::Namespace::SubCommand register "options-with-aliases", ::Commands::OptionsWithAliases register "variadic default", ::Commands::VariadicArguments diff --git a/spec/support/fixtures/with_zero_arity_block.rb b/spec/support/fixtures/with_zero_arity_block.rb index 2b5bcdf..b3cc1f9 100755 --- a/spec/support/fixtures/with_zero_arity_block.rb +++ b/spec/support/fixtures/with_zero_arity_block.rb @@ -23,6 +23,10 @@ register "root-command" do register "sub-command", Commands::RootCommands::SubCommand end + register "namespace", Commands::Namespace + register "namespace" do + register "sub-command", Commands::Namespace::SubCommand + end register "options-with-aliases", Commands::OptionsWithAliases register "variadic default", Commands::VariadicArguments diff --git a/spec/support/shared_examples/rendering.rb b/spec/support/shared_examples/rendering.rb index 61669a8..583ceb5 100644 --- a/spec/support/shared_examples/rendering.rb +++ b/spec/support/shared_examples/rendering.rb @@ -19,6 +19,7 @@ #{cmd} greeting [RESPONSE] #{cmd} hello # Print a greeting #{cmd} inherited [SUBCOMMAND] + #{cmd} namespace [SUBCOMMAND] # This is a namespace #{cmd} new PROJECT # Generate a new Foo project #{cmd} options-with-aliases # Accepts options with aliases #{cmd} root-command [ARGUMENT|SUBCOMMAND] # Root command with arguments and subcommands @@ -80,6 +81,7 @@ #{cmd} greeting [RESPONSE] #{cmd} hello # Print a greeting #{cmd} inherited [SUBCOMMAND] + #{cmd} namespace [SUBCOMMAND] # This is a namespace #{cmd} new PROJECT # Generate a new Foo project #{cmd} options-with-aliases # Accepts options with aliases #{cmd} root-command [ARGUMENT|SUBCOMMAND] # Root command with arguments and subcommands @@ -109,6 +111,7 @@ #{cmd} greeting [RESPONSE] #{cmd} hello # Print a greeting #{cmd} inherited [SUBCOMMAND] + #{cmd} namespace [SUBCOMMAND] # This is a namespace #{cmd} new PROJECT # Generate a new Foo project #{cmd} options-with-aliases # Accepts options with aliases #{cmd} root-command [ARGUMENT|SUBCOMMAND] # Root command with arguments and subcommands From 1cef94f0972d2f1e17b764dce18bbf3f4a9c7fcd Mon Sep 17 00:00:00 2001 From: Gustavo Ribeiro Date: Wed, 24 Jul 2024 06:34:23 -0300 Subject: [PATCH 2/4] Shows subcommands when root command doesn't implement #call --- lib/dry/cli.rb | 1 + spec/support/shared_examples/inherited_commands.rb | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/lib/dry/cli.rb b/lib/dry/cli.rb index d0b8c13..6de9689 100644 --- a/lib/dry/cli.rb +++ b/lib/dry/cli.rb @@ -111,6 +111,7 @@ def perform_registry(arguments) return usage(result) unless result.found? command, args = parse(result.command, result.arguments, result.names) + return usage(result) unless command.respond_to?(:call) result.before_callbacks.run(command, args) command.call(**args) diff --git a/spec/support/shared_examples/inherited_commands.rb b/spec/support/shared_examples/inherited_commands.rb index dae3a43..d5684df 100644 --- a/spec/support/shared_examples/inherited_commands.rb +++ b/spec/support/shared_examples/inherited_commands.rb @@ -18,6 +18,15 @@ expect(error).to eq(expected) end + it "shows subcommands when root command doesn't implement #call" do + error = capture_error { cli.call(arguments: %w[namespace]) } + expected = <<~DESC + Commands: + #{cmd} namespace sub-command # I'm a concrete command + DESC + expect(error).to eq(expected) + end + it "shows run's help" do output = capture_output { cli.call(arguments: %w[i run --help]) } expected = <<~DESC From 252a7c28baf37782e0f48b0fb679c26cf62c698e Mon Sep 17 00:00:00 2001 From: Gustavo Ribeiro Date: Wed, 24 Jul 2024 06:52:17 -0300 Subject: [PATCH 3/4] Shows root command help considering if it implements #call --- lib/dry/cli/banner.rb | 14 +++++++++++-- .../shared_examples/inherited_commands.rb | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/lib/dry/cli/banner.rb b/lib/dry/cli/banner.rb index bb1a474..1f686a2 100644 --- a/lib/dry/cli/banner.rb +++ b/lib/dry/cli/banner.rb @@ -37,9 +37,19 @@ def self.command_name(name) # @since 0.1.0 # @api private def self.command_name_and_arguments(command, name) - usage = "\nUsage:\n #{name}#{arguments(command)}" + usage = "\nUsage:\n" - return usage + " | #{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 end diff --git a/spec/support/shared_examples/inherited_commands.rb b/spec/support/shared_examples/inherited_commands.rb index d5684df..4643937 100644 --- a/spec/support/shared_examples/inherited_commands.rb +++ b/spec/support/shared_examples/inherited_commands.rb @@ -27,6 +27,27 @@ expect(error).to eq(expected) end + it "shows root command help considering if it implements #call" do + output = capture_output { cli.call(arguments: %w[namespace --help]) } + expected = <<~DESC + Command: + #{cmd} namespace + + Usage: + #{cmd} namespace SUBCOMMAND + + Description: + This is a namespace + + Subcommands: + sub-command # I'm a concrete command + + Options: + --help, -h # Print this help + DESC + expect(output).to eq(expected) + end + it "shows run's help" do output = capture_output { cli.call(arguments: %w[i run --help]) } expected = <<~DESC From 3b16959ba6e690e0ab84d8da1d406a14793dd8e5 Mon Sep 17 00:00:00 2001 From: Gustavo Ribeiro Date: Sat, 21 Sep 2024 19:19:48 -0300 Subject: [PATCH 4/4] Create `Dry::CLI::Namespace`, a class to group commands --- lib/dry/cli.rb | 32 ++++++- lib/dry/cli/banner.rb | 34 ++++++-- lib/dry/cli/namespace.rb | 86 +++++++++++++++++++ lib/dry/cli/usage.rb | 2 +- spec/support/fixtures/shared_commands.rb | 2 +- .../shared_examples/inherited_commands.rb | 6 +- 6 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 lib/dry/cli/namespace.rb diff --git a/lib/dry/cli.rb b/lib/dry/cli.rb index 6de9689..b281cdd 100644 --- a/lib/dry/cli.rb +++ b/lib/dry/cli.rb @@ -10,6 +10,7 @@ module Dry class CLI require "dry/cli/version" require "dry/cli/errors" + require "dry/cli/namespace" require "dry/cli/command" require "dry/cli/registry" require "dry/cli/parser" @@ -26,11 +27,36 @@ class CLI # @since 0.1.0 # @api private def self.command?(command) - case command + inherits?(command, Command) + end + + # Check if namespace + # + # @param namespace [Object] the namespace to check + # + # @return [TrueClass,FalseClass] true if instance of `Dry::CLI::Namespace` + # + # @since 1.1.1 + # @api private + def self.namespace?(namespace) + inherits?(namespace, Namespace) + end + + # Check if `obj` inherits from `klass` + # + # @param obj [Object] object to check + # @param klass [Object] class that should be inherited + # + # @return [TrueClass,FalseClass] true if `obj` inherits from `klass` + # + # @since 1.1.1 + # @api private + def self.inherits?(obj, klass) + case obj when Class - command.ancestors.include?(Command) + obj.ancestors.include?(klass) else - command.is_a?(Command) + obj.is_a?(klass) end end diff --git a/lib/dry/cli/banner.rb b/lib/dry/cli/banner.rb index 1f686a2..352e071 100644 --- a/lib/dry/cli/banner.rb +++ b/lib/dry/cli/banner.rb @@ -9,14 +9,26 @@ class CLI # @since 0.1.0 # @api private module Banner - # Prints command banner + # Prints command/namespace banner # - # @param command [Dry::CLI::Command] the command + # @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") + end + + # @since 1.1.1 + # @api private + def self.command_banner(command, name) [ command_name(name), command_name_and_arguments(command, name), @@ -25,13 +37,25 @@ def self.call(command, name) command_arguments(command), command_options(command), command_examples(command, name) - ].compact.join("\n") + ] + end + + # @since 1.1.1 + # @api private + def self.namespace_banner(namespace, name) + [ + command_name(name, "Namespace"), + command_name_and_arguments(namespace, name), + command_description(namespace), + command_subcommands(namespace), + command_options(namespace) + ] end # @since 0.1.0 # @api private - def self.command_name(name) - "Command:\n #{name}" + def self.command_name(name, label = "Command") + "#{label}:\n #{name}" end # @since 0.1.0 diff --git a/lib/dry/cli/namespace.rb b/lib/dry/cli/namespace.rb new file mode 100644 index 0000000..f89f15c --- /dev/null +++ b/lib/dry/cli/namespace.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Dry + class CLI + # Base class for namespaces + # + # @since 1.1.1 + class Namespace + # @since 1.1.1 + # @api private + def self.inherited(base) + super + base.class_eval do + @description = nil + @examples = [] + @arguments = [] + @options = [] + @subcommands = [] + end + base.extend ClassMethods + end + + # @since 1.1.1 + # @api private + module ClassMethods + # @since 1.1.1 + # @api private + attr_reader :description + + # @since 1.1.1 + # @api private + attr_reader :examples + + # @since 1.1.1 + # @api private + attr_reader :arguments + + # @since 1.1.1 + # @api private + attr_reader :options + + # @since 1.1.1 + # @api private + attr_accessor :subcommands + end + + # Set the description of the namespace + # + # @param description [String] the description + # + # @since 1.1.1 + # + # @example + # require "dry/cli" + # + # class YourNamespace < Dry::CLI::Namespace + # desc "Collection of really useful commands" + # + # class YourCommand < Dry::CLI::Command + # # ... + # end + # end + def self.desc(description) + @description = description + end + + # @since 1.1.1 + # @api private + def self.default_params + {} + end + + # @since 1.1.1 + # @api private + def self.required_arguments + [] + end + + # @since 1.1.1 + # @api private + def self.subcommands + subcommands + end + end + end +end diff --git a/lib/dry/cli/usage.rb b/lib/dry/cli/usage.rb index e19a0ba..f7de207 100644 --- a/lib/dry/cli/usage.rb +++ b/lib/dry/cli/usage.rb @@ -66,7 +66,7 @@ def self.arguments(command) # @since 0.1.0 # @api private def self.description(command) - return unless CLI.command?(command) + return unless CLI.command?(command) || CLI.namespace?(command) " # #{command.description}" unless command.description.nil? end diff --git a/spec/support/fixtures/shared_commands.rb b/spec/support/fixtures/shared_commands.rb index 3cde0f8..05256f2 100644 --- a/spec/support/fixtures/shared_commands.rb +++ b/spec/support/fixtures/shared_commands.rb @@ -438,7 +438,7 @@ def call(**params) end end - class Namespace < Dry::CLI::Command + class Namespace < Dry::CLI::Namespace desc "This is a namespace" class SubCommand < Dry::CLI::Command diff --git a/spec/support/shared_examples/inherited_commands.rb b/spec/support/shared_examples/inherited_commands.rb index 4643937..8c62fb5 100644 --- a/spec/support/shared_examples/inherited_commands.rb +++ b/spec/support/shared_examples/inherited_commands.rb @@ -18,7 +18,7 @@ expect(error).to eq(expected) end - it "shows subcommands when root command doesn't implement #call" do + it "shows subcommands when calling a namespace" do error = capture_error { cli.call(arguments: %w[namespace]) } expected = <<~DESC Commands: @@ -27,10 +27,10 @@ expect(error).to eq(expected) end - it "shows root command help considering if it implements #call" do + it "shows namespace help when using --help" do output = capture_output { cli.call(arguments: %w[namespace --help]) } expected = <<~DESC - Command: + Namespace: #{cmd} namespace Usage: