Repository: wemake-services/wemake-python-styleguide

Issue:
Complexity: find PM with too many `case` statements
We need to report `match` with too many `case` statements. I guess 7+ should be the default limit.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/pull/3218?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`aa7b453`)](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/commit/aa7b453cdedcc4529cf7f2a391958f62877542df?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services) to head [(`0505ecc`)](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/commit/0505ecc0683bcf52fa498da763fa250f4c74d1f4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services).

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


```diff
@@            Coverage Diff            @@
##            master     #3218   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          367       368    +1     
  Lines        12370     12362    -8     
  Branches       865       858    -7     
=========================================
- Hits         12370     12362    -8     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/pull/3218?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services).   
: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=wemake-services).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9fa8fd59..3d337344 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -102,6 +102,7 @@ Semantic versioning in our case means:
 - Adds a new rule to detect duplicate conditions in `if`s and `elif`s, #2241
 - Adds a new rule to detect duplicate `case` pattens in `match`, #3206
 - Adds a new rule to find too many `match` subjects, #3201
+- Adds a new rule to detect too many `case` statements, #3202
 - Adds a new rule to find too complex `except` with too many exceptions
 - Adds a new rule to find too many `PEP695` type params
 - Adds a new rule to find useless ternary expressions, #1706
diff --git a/pyproject.toml b/pyproject.toml
index 7507d873..45b5855c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -267,7 +267,7 @@ norecursedirs = [
 # Coverage configuration: https://coverage.readthedocs.io/
 
 # We don't need to cover some files. They are fully checked with mypy.
-# And don"t contain any logic.
+# And don't contain any logic.
 omit = [
   # Does not contain runtime logic:
   "wemake_python_styleguide/types.py",
diff --git a/wemake_python_styleguide/options/config.py b/wemake_python_styleguide/options/config.py
index f7c14e92..b889a7bc 100644
--- a/wemake_python_styleguide/options/config.py
+++ b/wemake_python_styleguide/options/config.py
@@ -156,6 +156,9 @@ You can also show all options that ``flake8`` supports by running:
 - ``max-match-subjects`` - maximum number of subjects in a match statement,
     defaults to
     :str:`wemake_python_styleguide.options.defaults.MAX_MATCH_SUBJECTS`
+- ``max-match-cases`` - maximum number of cases in a match block of code
+    defaults to
+    :str:`wemake_python_styleguide.options.defaults.MAX_MATCH_CASES`
 
 .. rubric:: Formatter options
 
@@ -431,6 +434,11 @@ class Configuration:
             defaults.MAX_MATCH_SUBJECTS,
             'Maximum number of subjects in a match statement.',
         ),
+        _Option(
+            '--max-match-cases',
+            defaults.MAX_MATCH_CASES,
+            'Maximum number of match cases in a single match.',
+        ),
         # Formatter:
         _Option(
             '--show-violation-links',
diff --git a/wemake_python_styleguide/options/defaults.py b/wemake_python_styleguide/options/defaults.py
index 7d7bcfc4..21baf272 100644
--- a/wemake_python_styleguide/options/defaults.py
+++ b/wemake_python_styleguide/options/defaults.py
@@ -143,6 +143,9 @@ MAX_TYPE_PARAMS: Final = 6  # 7-1, guessed
 #: Maximum number of subjects in a ``match`` statement.
 MAX_MATCH_SUBJECTS: Final = 7  # 7 +- 0, guessed
 
+#: Maximum number of subjects in match statement.
+MAX_MATCH_CASES: Final = 7  # guessed
+
 # ==========
 # Formatter:
 # ==========
diff --git a/wemake_python_styleguide/options/validation.py b/wemake_python_styleguide/options/validation.py
index 9fc04ade..ddb107b5 100644
--- a/wemake_python_styleguide/options/validation.py
+++ b/wemake_python_styleguide/options/validation.py
@@ -99,6 +99,7 @@ class _ValidatedOptions:
     max_tuple_unpack_length: int = attr.ib(validator=[_min_max(min=1)])
     max_type_params: int = attr.ib(validator=[_min_max(min=1)])
     max_match_subjects: int = attr.ib(validator=[_min_max(min=1)])
+    max_match_cases: int = attr.ib(validator=[_min_max(min=1)])
     show_violation_links: bool
     exps_for_one_empty_line: int
 
diff --git a/wemake_python_styleguide/presets/topics/complexity.py b/wemake_python_styleguide/presets/topics/complexity.py
index 3d5f4e10..d8d3a2c5 100644
--- a/wemake_python_styleguide/presets/topics/complexity.py
+++ b/wemake_python_styleguide/presets/topics/complexity.py
@@ -38,4 +38,5 @@ PRESET: Final = (
     calls.CallChainsVisitor,
     annotations.AnnotationComplexityVisitor,
     pm.MatchSubjectsVisitor,
+    pm.MatchCasesVisitor,
 )
diff --git a/wemake_python_styleguide/types.py b/wemake_python_styleguide/types.py
index 7a28cb12..feea709e 100644
--- a/wemake_python_styleguide/types.py
+++ b/wemake_python_styleguide/types.py
@@ -234,3 +234,6 @@ class ConfigurationOptions(Protocol):
 
     @property
     def max_match_subjects(self) -> int: ...
+
+    @property
+    def max_match_cases(self) -> int: ...
diff --git a/wemake_python_styleguide/violations/complexity.py b/wemake_python_styleguide/violations/complexity.py
index df285a88..9144a0da 100644
--- a/wemake_python_styleguide/violations/complexity.py
+++ b/wemake_python_styleguide/violations/complexity.py
@@ -62,6 +62,7 @@ Summary
    TooManyExceptExceptionsViolation
    TooManyTypeParamsViolation
    TooManyMatchSubjectsViolation
+   TooManyMatchCaseViolation
 
 Module complexity
 -----------------
@@ -107,6 +108,7 @@ Structure complexity
 .. autoclass:: TooManyExceptExceptionsViolation
 .. autoclass:: TooManyTypeParamsViolation
 .. autoclass:: TooManyMatchSubjectsViolation
+.. autoclass:: TooManyMatchCaseViolation
 
 """
 
@@ -1359,3 +1361,34 @@ class TooManyMatchSubjectsViolation(ASTViolation):
 
     error_template = 'Found too many subjects in `match` statement: {0}'
     code = 241
+
+
+@final
+class TooManyMatchCaseViolation(ASTViolation):
+    """
+    Forbids to have too many match cases.
+
+    Reasoning:
+        Too many match cases means that you are probably overly complicate
+        the object that you are matching right now.
+        It would be really hard for users
+        to manually add all match cases.
+
+    Solution:
+        Refactor the ``match`` statement by breaking the logic into smaller,
+        focused functions. This will improve readability and maintainability.
+        Split complex logic into separate functions to keep each one concise,
+        reducing the size of the ``match`` block and making the code easier
+        to understand and modify.
+
+
+    Configuration:
+        This rule is configurable with ``--max-match-cases``.
+        Default:
+        :str:`wemake_python_styleguide.options.defaults.MAX_MATCH_CASES`
+
+    .. versionadded:: 1.0.0
+    """
+
+    error_template = 'Found too many cases in `match` block: {0}'
+    code = 242
diff --git a/wemake_python_styleguide/visitors/ast/complexity/pm.py b/wemake_python_styleguide/visitors/ast/complexity/pm.py
index bd2076fa..354c219e 100644
--- a/wemake_python_styleguide/visitors/ast/complexity/pm.py
+++ b/wemake_python_styleguide/visitors/ast/complexity/pm.py
@@ -26,3 +26,23 @@ class MatchSubjectsVisitor(BaseNodeVisitor):
                 baseline=self.options.max_match_subjects,
             )
         )
+
+
+@final
+class MatchCasesVisitor(BaseNodeVisitor):
+    """Finds excessive match cases in `match` statements."""
+
+    def visit_Match(self, node: ast.Match) -> None:
+        """Finds all `match` statements and checks their cases."""
+        self._check_match_cases_count(node)
+        self.generic_visit(node)
+
+    def _check_match_cases_count(self, node: ast.Match) -> None:
+        if len(node.cases) > self.options.max_match_cases:
+            self.add_violation(
+                violation=complexity.TooManyMatchCaseViolation(
+                    text=str(len(node.cases)),
+                    node=node,
+                    baseline=self.options.max_match_cases,
+                ),
+            )


Test patch:
diff --git a/tests/fixtures/noqa/noqa.py b/tests/fixtures/noqa/noqa.py
index 60e4264d..539ca3fd 100644
--- a/tests/fixtures/noqa/noqa.py
+++ b/tests/fixtures/noqa/noqa.py
@@ -636,6 +636,25 @@ match inst1, inst2, inst3, inst4, inst5, inst6, inst7, inst8:  # noqa: WPS241
         my_print('except')
 
 
+match x:  # noqa: WPS242
+    case 1:
+        my_print('first')
+    case 2:
+        my_print('second')
+    case 3:
+        my_print('third')
+    case 4:
+        my_print('fourth')
+    case 5:
+        my_print('fifth')
+    case 6:
+        my_print('sixth')
+    case 7:
+        my_print('seventh')
+    case 8:
+        my_print('eighth')
+
+
 my_print("""
 text
 """)  # noqa: WPS462
diff --git a/tests/test_checker/test_noqa.py b/tests/test_checker/test_noqa.py
index b5a7437a..08fd69cc 100644
--- a/tests/test_checker/test_noqa.py
+++ b/tests/test_checker/test_noqa.py
@@ -93,6 +93,7 @@ SHOULD_BE_RAISED = types.MappingProxyType(
         'WPS239': 1,
         'WPS240': 0,  # only triggers on 3.12+
         'WPS241': 1,
+        'WPS242': 1,
         'WPS300': 1,
         'WPS301': 1,
         'WPS302': 0,  # disabled since 1.0.0
diff --git a/tests/test_visitors/test_ast/test_complexity/test_pm/test_max_match_cases.py b/tests/test_visitors/test_ast/test_complexity/test_pm/test_max_match_cases.py
new file mode 100644
index 00000000..ec1aee19
--- /dev/null
+++ b/tests/test_visitors/test_ast/test_complexity/test_pm/test_max_match_cases.py
@@ -0,0 +1,112 @@
+import pytest
+
+from wemake_python_styleguide.violations.complexity import (
+    TooManyMatchCaseViolation,
+)
+from wemake_python_styleguide.visitors.ast.complexity.pm import (
+    MatchCasesVisitor,
+)
+
+match_cases1 = """
+match x:
+    case _: ...
+"""
+
+match_cases4 = """
+match x:
+    case 1: ...
+    case 2: ...
+    case 3: ...
+    case 4: ...
+"""
+
+match_cases6 = """
+match x:
+    case 1: ...
+    case 2: ...
+    case 3: ...
+    case 4: ...
+    case 5: ...
+    case 6: ...
+"""
+
+match_cases7 = """
+match x:
+    case 1: ...
+    case 2: ...
+    case 3: ...
+    case 4: ...
+    case 5: ...
+    case 6: ...
+    case 7: ...
+    case 8: ...
+"""
+
+
+@pytest.mark.parametrize(
+    'code',
+    [
+        match_cases7,
+    ],
+)
+def test_match_cases_wrong_count(
+    assert_errors,
+    assert_error_text,
+    parse_ast_tree,
+    code,
+    default_options,
+):
+    """Testing that default settings raise a warning."""
+    tree = parse_ast_tree(code)
+
+    visitor = MatchCasesVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [TooManyMatchCaseViolation])
+    assert_error_text(visitor, '8', baseline=default_options.max_match_cases)
+
+
+@pytest.mark.parametrize(
+    'code',
+    [
+        match_cases1,
+        match_cases6,
+    ],
+)
+def test_match_cases_correct_count(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    default_options,
+):
+    """Testing that default settings do not raise a warning."""
+    tree = parse_ast_tree(code)
+
+    visitor = MatchCasesVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])
+
+
+@pytest.mark.parametrize(
+    'code',
+    [
+        match_cases4,
+        match_cases6,
+        match_cases7,
+    ],
+)
+def test_match_cases_configured_count(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    options,
+):
+    """Testing that settings can reflect the change."""
+    tree = parse_ast_tree(code)
+
+    option_values = options(max_match_cases=2)
+    visitor = MatchCasesVisitor(option_values, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [TooManyMatchCaseViolation])