Repository: pdm-project/pdm
Pull request #3274 (2024-11-11 10:22:16)

feat: Output similar commands or script command when the input command is not correct
Signed-off-by: Manjusaka <me@manjusaka.me>

## Pull Request Checklist

- [x] A news fragment is added in `news/` describing what is new.
- [x] Test cases added for changed code.

## Describe what you have changed in this PR.

Support #3270 


Zheaoli: ![image](https://github.com/user-attachments/assets/3b3ad721-df60-494e-8645-683d01ce1ff3)

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pdm-project/pdm/pull/3274?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) Report
Attention: Patch coverage is `95.83333%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 85.30%. Comparing base [(`91e02dd`)](https://app.codecov.io/gh/pdm-project/pdm/commit/91e02dd4aacb064c1e005288cb8f850fe26bd91b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) to head [(`0fee0cd`)](https://app.codecov.io/gh/pdm-project/pdm/commit/0fee0cd8dc05d7b1b3b800668e4a7efbf0e762ce?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).

| [Files with missing lines](https://app.codecov.io/gh/pdm-project/pdm/pull/3274?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Patch % | Lines |
|---|---|---|
| [src/pdm/core.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3274?src=pr&el=tree&filepath=src%2Fpdm%2Fcore.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jb3JlLnB5) | 85.71% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3274?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #3274   +/-   ##
=======================================
  Coverage   85.29%   85.30%           
=======================================
  Files         112      112           
  Lines       11375    11395   +20     
  Branches     2479     2484    +5     
=======================================
+ Hits         9702     9720   +18     
- Misses       1145     1146    +1     
- Partials      528      529    +1     
```

| [Flag](https://app.codecov.io/gh/pdm-project/pdm/pull/3274/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/pdm-project/pdm/pull/3274/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | `85.09% <95.83%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pdm-project/pdm/pull/3274?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).


Patch:
diff --git a/news/3270.feat.md b/news/3270.feat.md
new file mode 100644
index 00000000..06ecd3e0
--- /dev/null
+++ b/news/3270.feat.md
@@ -0,0 +1,1 @@
+Output similar commands or script command when the input command is not correct
diff --git a/pyproject.toml b/pyproject.toml
index e754afd0..02e02185 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -220,7 +220,7 @@ testpaths = [
 ]
 
 [tool.codespell]
-ignore-words-list = "ba,overriden,te"
+ignore-words-list = "ba,overriden,te,instal"
 
 [tool.coverage.run]
 branch = true
diff --git a/src/pdm/cli/utils.py b/src/pdm/cli/utils.py
index 52c60af2..928e4d23 100644
--- a/src/pdm/cli/utils.py
+++ b/src/pdm/cli/utils.py
@@ -7,20 +7,12 @@ import os
 import re
 import sys
 from collections import OrderedDict
+from difflib import SequenceMatcher
 from fnmatch import fnmatch
 from gettext import gettext as _
 from json import dumps
 from pathlib import Path
-from typing import (
-    TYPE_CHECKING,
-    Any,
-    Callable,
-    Iterable,
-    Mapping,
-    MutableMapping,
-    cast,
-    no_type_check,
-)
+from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping, MutableMapping, cast, no_type_check
 
 from packaging.specifiers import SpecifierSet
 from resolvelib.structs import DirectedGraph
@@ -156,6 +148,36 @@ class ArgumentParser(argparse.ArgumentParser):
         return args, argv
 
 
+def find_similar_text(origin_name: str, target_names: list[str], ratio: float = 0.5) -> list[str]:
+    results = []
+    for name in target_names:
+        ratio = SequenceMatcher(None, origin_name, name).ratio()
+        if ratio > 0.4:
+            results.append((name, ratio))
+    return [name for name, _ in sorted(results, key=lambda x: x[1], reverse=True)]
+
+
+def format_similar_command(root_command: str, commands: list[str], script_commands: list[str]) -> str:
+    similar_commands = find_similar_text(root_command, commands)
+    similar_script_commands = find_similar_text(root_command, script_commands)
+    commands_text = "\n".join([f"    - {cmd}" for cmd in similar_commands])
+    script_commands_text = "\n".join([f"    - {cmd}" for cmd in similar_script_commands])
+    message = f"""[red]Command not found: {root_command}[/]
+    """
+    if commands_text:
+        message += f"""
+[green]Did you mean one of these command?
+{commands_text}[/]
+"""
+
+    if script_commands_text:
+        message += f"""
+[yellow]Or one of these script command?
+{script_commands_text}[/]
+"""
+    return message
+
+
 class ErrorArgumentParser(ArgumentParser):
     """A subclass of argparse.ArgumentParser that raises
     parsing error rather than exiting.
diff --git a/src/pdm/core.py b/src/pdm/core.py
index a41752c1..13e379da 100644
--- a/src/pdm/core.py
+++ b/src/pdm/core.py
@@ -20,14 +20,14 @@ from datetime import datetime
 from functools import cached_property
 from pathlib import Path
 from tempfile import TemporaryDirectory
-from typing import TYPE_CHECKING, cast
+from typing import TYPE_CHECKING, ClassVar, cast
 
 import tomlkit.exceptions
 
 from pdm import termui
 from pdm.__version__ import __version__
 from pdm.cli.options import ignore_python_option, no_cache_option, non_interactive_option, pep582_option, verbose_option
-from pdm.cli.utils import ArgumentParser, ErrorArgumentParser
+from pdm.cli.utils import ArgumentParser, ErrorArgumentParser, format_similar_command
 from pdm.compat import importlib_metadata
 from pdm.exceptions import PdmArgumentError, PdmUsageError
 from pdm.installers import InstallManager
@@ -70,6 +70,7 @@ class Core:
     project_class = Project
     repository_class: type[BaseRepository] = PyPIRepository
     install_manager_class = InstallManager
+    commands: ClassVar[list[str]] = []
 
     def __init__(self) -> None:
         self.version = __version__
@@ -256,7 +257,9 @@ class Core:
 
         project = self.ensure_project(options, obj)
         if root_script and root_script not in project.scripts:
-            self.parser.error(f"Script unknown: {root_script}")
+            message = format_similar_command(root_script, self.commands, list(project.scripts.keys()))
+            message = termui.style(message)
+            self.parser.error(message)
 
         try:
             self.handle(project, options)
@@ -289,6 +292,8 @@ class Core:
                 is used
         """
         assert self.subparsers
+        if name:
+            self.commands.append(name)
         command.register_to(self.subparsers, name)
 
     @staticmethod


Test patch:
diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py
index 3e95b17d..dec6cb04 100644
--- a/tests/cli/test_run.py
+++ b/tests/cli/test_run.py
@@ -923,7 +923,7 @@ def test_run_shortcuts_dont_override_commands(project, pdm, capfd, mocker):
 def test_run_shortcut_fail_with_usage_if_script_not_found(project, pdm):
     result = pdm(["whatever"], obj=project)
     assert result.exit_code != 0
-    assert "Script unknown: whatever" in result.stderr
+    assert "Command not found: whatever" in result.stderr
     assert "Usage" in result.stderr
 
 
diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py
index d3993efc..81f65969 100644
--- a/tests/cli/test_utils.py
+++ b/tests/cli/test_utils.py
@@ -2,3 +2,9 @@ def test_help_with_unknown_arguments(pdm):
     result = pdm(["add", "--unknown-args"])
     assert "Usage: pdm add " in result.stderr
     assert result.exit_code == 2
+
+
+def test_output_similar_command_when_typo(pdm):
+    result = pdm(["instal"])
+    assert "install" in result.stderr
+    assert result.exit_code == 2