From 326a5ea488d6a3ee173c50c5a4e2361530d23380 Mon Sep 17 00:00:00 2001
From: Benoit Tigeot <benoittgt@users.noreply.github.com>
Date: Sat, 21 Sep 2024 05:06:36 +0200
Subject: [PATCH] Provide help when there is a typo in a command (#138)

Add a basic spell checker to make suggestions when the command could have a typo.
---
 Gemfile                                     |  2 +-
 lib/dry/cli.rb                              | 11 ++++--
 lib/dry/cli/spell_checker.rb                | 38 ++++++++++++++++++
 spec/integration/inherited_commands_spec.rb |  1 +
 spec/integration/spell_checker_spec.rb      | 44 +++++++++++++++++++++
 5 files changed, 91 insertions(+), 5 deletions(-)
 create mode 100644 lib/dry/cli/spell_checker.rb
 create mode 100644 spec/integration/spell_checker_spec.rb

diff --git a/Gemfile b/Gemfile
index e653285..116f528 100644
--- a/Gemfile
+++ b/Gemfile
@@ -9,5 +9,5 @@ gemspec
 gem "backports", "~> 3.15.0", require: false
 
 unless ENV["CI"]
-  gem "yard",   require: false
+  gem "yard", require: false
 end
diff --git a/lib/dry/cli.rb b/lib/dry/cli.rb
index d0b8c13..5f93860 100644
--- a/lib/dry/cli.rb
+++ b/lib/dry/cli.rb
@@ -14,6 +14,7 @@ class CLI
     require "dry/cli/registry"
     require "dry/cli/parser"
     require "dry/cli/usage"
+    require "dry/cli/spell_checker"
     require "dry/cli/banner"
     require "dry/cli/inflector"
 
@@ -108,7 +109,7 @@ def perform_command(arguments)
     # @api private
     def perform_registry(arguments)
       result = registry.get(arguments)
-      return usage(result) unless result.found?
+      return spell_checker(result, arguments) unless result.found?
 
       command, args = parse(result.command, result.arguments, result.names)
 
@@ -161,9 +162,11 @@ def error(result)
       exit(1)
     end
 
-    # @since 0.1.0
-    # @api private
-    def usage(result)
+    # @since 1.1.1
+    def spell_checker(result, arguments)
+      spell_checker = SpellChecker.call(result, arguments)
+      err.puts spell_checker if spell_checker
+      puts
       err.puts Usage.call(result)
       exit(1)
     end
diff --git a/lib/dry/cli/spell_checker.rb b/lib/dry/cli/spell_checker.rb
new file mode 100644
index 0000000..8080d5c
--- /dev/null
+++ b/lib/dry/cli/spell_checker.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require "dry/cli/program_name"
+require "did_you_mean"
+
+module Dry
+  class CLI
+    # Command(s) usage
+    #
+    # @since 1.1.1
+    # @api private
+    module SpellChecker
+      # @since 1.1.1
+      # @api private
+      def self.call(result, arguments)
+        commands = result.children.keys
+        cmd = cmd_to_spell(arguments, result.names)
+
+        suggestions = DidYouMean::SpellChecker.new(dictionary: commands).correct(cmd.first)
+        if suggestions.any?
+          "I don't know how to '#{cmd.join(" ")}'. Did you mean: '#{suggestions.first}' ?"
+        end
+      end
+
+      # @since 1.1.1
+      # @api private
+      def self.cmd_to_spell(arguments, result_names)
+        arguments - result_names
+      end
+
+      # @since 1.1.1
+      # @api private
+      def self.ignore?(cmd)
+        cmd.empty? || cmd.first.start_with?("-")
+      end
+    end
+  end
+end
diff --git a/spec/integration/inherited_commands_spec.rb b/spec/integration/inherited_commands_spec.rb
index c872df8..9ed9621 100644
--- a/spec/integration/inherited_commands_spec.rb
+++ b/spec/integration/inherited_commands_spec.rb
@@ -10,6 +10,7 @@
           based logs APP                  # Display recent log output
           based run APP CMD               # Run a one-off process inside your app
           based subrun APP CMD
+
       OUT
       expect(output).to eq(expected)
     end
diff --git a/spec/integration/spell_checker_spec.rb b/spec/integration/spell_checker_spec.rb
new file mode 100644
index 0000000..30ab94a
--- /dev/null
+++ b/spec/integration/spell_checker_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require "open3"
+
+RSpec.describe "Spell checker" do
+  it "print similar command when there is a command with a typo" do
+    _, stderr, = Open3.capture3("foo routs")
+
+    expected = <<~DESC
+      I don't know how to 'routs'. Did you mean: 'routes' ?
+      Commands:
+        foo assets [SUBCOMMAND]
+        foo callbacks DIR                                          # Command with callbacks
+        foo console                                                # Starts Foo console
+        foo db [SUBCOMMAND]
+        foo destroy [SUBCOMMAND]
+        foo exec TASK [DIRS]                                       # Execute a task
+        foo generate [SUBCOMMAND]
+        foo greeting [RESPONSE]
+        foo hello                                                  # Print a greeting
+        foo new PROJECT                                            # Generate a new Foo project
+        foo root-command [ARGUMENT|SUBCOMMAND]                     # Root command with arguments and subcommands
+        foo routes                                                 # Print routes
+        foo server                                                 # Start Foo server (only for development)
+        foo sub [SUBCOMMAND]
+        foo variadic [SUBCOMMAND]
+        foo version                                                # Print Foo version
+    DESC
+
+    expect(stderr).to eq(expected)
+  end
+
+  it "handles typos in subcommands" do
+    _, stderr, = Open3.capture3("foo sub comand")
+
+    expected = <<~DESC
+      I don't know how to 'comand'. Did you mean: 'command' ?
+      Commands:
+        foo sub command         # Override a subcommand
+    DESC
+
+    expect(stderr).to eq(expected)
+  end
+end