Repository: wemake-services/wemake-python-styleguide

Issue:
`match` + `case` does not increase cognitive complexity


`case` in `match` do the same as `if` does. We need to count each `case` as well. We need to add `ast.match_case` to `_CONTROL_FLOW_BREAKERS`. See https://github.com/wemake-services/wemake-python-styleguide/blob/a47422db1e4261b8be23dd2d015d0db977100b53/wemake_python_styleguide/logic/complexity/cognitive.py#L25-L31

We also need to add a test case for this to https://github.com/wemake-services/wemake-python-styleguide/blob/master/tests/test_logic/test_complexity/test_cognitive/test_cognitive_complexity.py

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 91bdc341..9fa8fd59 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -101,6 +101,7 @@ Semantic versioning in our case means:
 - Adds a new rule to check problematic function params, #1343
 - 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 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
@@ -133,6 +134,7 @@ Semantic versioning in our case means:
 - Fixes several violations not been detected in `case:` statements
 - Fixes `WPS434` not detecting reassignments in `:=` case
 - Fixes `WPS314` not detecting `match` statements
+- Fixes `match` + `case` does not increase cognitive complexity
 
 ### Misc
 
diff --git a/wemake_python_styleguide/logic/complexity/cognitive.py b/wemake_python_styleguide/logic/complexity/cognitive.py
index 7843668e..43b1d0b5 100644
--- a/wemake_python_styleguide/logic/complexity/cognitive.py
+++ b/wemake_python_styleguide/logic/complexity/cognitive.py
@@ -28,6 +28,7 @@ _CONTROL_FLOW_BREAKERS: AnyNodes = (
     ast.AsyncFor,
     ast.While,
     ast.IfExp,
+    ast.match_case,
 )
 
 #: Control flow nodes that increment.
diff --git a/wemake_python_styleguide/logic/tree/compares.py b/wemake_python_styleguide/logic/tree/compares.py
index b11c5cb4..4bb37821 100644
--- a/wemake_python_styleguide/logic/tree/compares.py
+++ b/wemake_python_styleguide/logic/tree/compares.py
@@ -2,10 +2,9 @@ import ast
 import types
 from collections import defaultdict
 from collections.abc import Mapping
-from typing import Final, TypeAlias
+from typing import Final, TypeAlias, final
 
 import attr
-from typing_extensions import final
 
 from wemake_python_styleguide.logic import source
 
diff --git a/wemake_python_styleguide/logic/tree/pattern_matching.py b/wemake_python_styleguide/logic/tree/pattern_matching.py
index 8c4f3937..e7e638b6 100644
--- a/wemake_python_styleguide/logic/tree/pattern_matching.py
+++ b/wemake_python_styleguide/logic/tree/pattern_matching.py
@@ -2,7 +2,11 @@ import ast
 from collections.abc import Iterable
 
 from wemake_python_styleguide.logic.nodes import get_parent
+from wemake_python_styleguide.logic.tree import (
+    operators,
+)
 from wemake_python_styleguide.logic.walk import get_subnodes_by_type
+from wemake_python_styleguide.logic.walrus import get_assigned_expr
 
 
 def get_explicit_as_names(
@@ -21,3 +25,23 @@ def get_explicit_as_names(
             and match_as.name
         ):
             yield match_as
+
+
+def is_constant_subject(condition: ast.AST | list[ast.expr]) -> bool:
+    """Detect constant subjects for `ast.Match` nodes."""
+    if isinstance(condition, list):
+        return all(is_constant_subject(node) for node in condition)
+    node = operators.unwrap_unary_node(get_assigned_expr(condition))
+    if isinstance(node, ast.Constant):
+        return True
+    if isinstance(node, ast.Tuple | ast.List | ast.Set):
+        return is_constant_subject(node.elts)
+    if isinstance(node, ast.Dict):
+        return (
+            not any(dict_key is None for dict_key in node.keys)
+            and is_constant_subject([
+                dict_key for dict_key in node.keys if dict_key is not None
+            ])
+            and is_constant_subject(node.values)
+        )
+    return False
diff --git a/wemake_python_styleguide/options/config.py b/wemake_python_styleguide/options/config.py
index 2bf0692e..f7c14e92 100644
--- a/wemake_python_styleguide/options/config.py
+++ b/wemake_python_styleguide/options/config.py
@@ -153,6 +153,9 @@ You can also show all options that ``flake8`` supports by running:
 - ``max-type-params`` - maximum number of PEP695 type parameters,
     defaults to
     :str:`wemake_python_styleguide.options.defaults.MAX_TYPE_PARAMS`
+- ``max-match-subjects`` - maximum number of subjects in a match statement,
+    defaults to
+    :str:`wemake_python_styleguide.options.defaults.MAX_MATCH_SUBJECTS`
 
 .. rubric:: Formatter options
 
@@ -423,6 +426,11 @@ class Configuration:
             defaults.MAX_TYPE_PARAMS,
             'Maximum number of PEP695 type parameters.',
         ),
+        _Option(
+            '--max-match-subjects',
+            defaults.MAX_MATCH_SUBJECTS,
+            'Maximum number of subjects in a match statement.',
+        ),
         # Formatter:
         _Option(
             '--show-violation-links',
diff --git a/wemake_python_styleguide/options/defaults.py b/wemake_python_styleguide/options/defaults.py
index aae52e1b..7d7bcfc4 100644
--- a/wemake_python_styleguide/options/defaults.py
+++ b/wemake_python_styleguide/options/defaults.py
@@ -138,7 +138,10 @@ MAX_IMPORT_FROM_MEMBERS: Final = 8  # guessed
 MAX_TUPLE_UNPACK_LENGTH: Final = 4  # guessed
 
 #: Maximum number of PEP695 type parameters.
-MAX_TYPE_PARAMS: Final = 6  # guessed
+MAX_TYPE_PARAMS: Final = 6  # 7-1, guessed
+
+#: Maximum number of subjects in a ``match`` statement.
+MAX_MATCH_SUBJECTS: Final = 7  # 7 +- 0, guessed
 
 # ==========
 # Formatter:
diff --git a/wemake_python_styleguide/options/validation.py b/wemake_python_styleguide/options/validation.py
index 63df15cb..9fc04ade 100644
--- a/wemake_python_styleguide/options/validation.py
+++ b/wemake_python_styleguide/options/validation.py
@@ -98,6 +98,7 @@ class _ValidatedOptions:
     max_import_from_members: int = attr.ib(validator=[_min_max(min=1)])
     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)])
     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 2f59d2ee..3d5f4e10 100644
--- a/wemake_python_styleguide/presets/topics/complexity.py
+++ b/wemake_python_styleguide/presets/topics/complexity.py
@@ -12,6 +12,7 @@ from wemake_python_styleguide.visitors.ast.complexity import (  # noqa: WPS235
     nested,
     offset,
     overuses,
+    pm,
 )
 
 #: Used to store all complexity related visitors to be later passed to checker:
@@ -36,4 +37,5 @@ PRESET: Final = (
     access.AccessVisitor,
     calls.CallChainsVisitor,
     annotations.AnnotationComplexityVisitor,
+    pm.MatchSubjectsVisitor,
 )
diff --git a/wemake_python_styleguide/types.py b/wemake_python_styleguide/types.py
index 07ad2619..7a28cb12 100644
--- a/wemake_python_styleguide/types.py
+++ b/wemake_python_styleguide/types.py
@@ -231,3 +231,6 @@ class ConfigurationOptions(Protocol):
 
     @property
     def exps_for_one_empty_line(self) -> int: ...
+
+    @property
+    def max_match_subjects(self) -> int: ...
diff --git a/wemake_python_styleguide/violations/complexity.py b/wemake_python_styleguide/violations/complexity.py
index 0e0344f6..df285a88 100644
--- a/wemake_python_styleguide/violations/complexity.py
+++ b/wemake_python_styleguide/violations/complexity.py
@@ -61,6 +61,7 @@ Summary
    TooManyRaisesViolation
    TooManyExceptExceptionsViolation
    TooManyTypeParamsViolation
+   TooManyMatchSubjectsViolation
 
 Module complexity
 -----------------
@@ -105,6 +106,7 @@ Structure complexity
 .. autoclass:: TooManyRaisesViolation
 .. autoclass:: TooManyExceptExceptionsViolation
 .. autoclass:: TooManyTypeParamsViolation
+.. autoclass:: TooManyMatchSubjectsViolation
 
 """
 
@@ -1329,3 +1331,31 @@ class TooManyTypeParamsViolation(ASTViolation):
 
     error_template = 'Found too many type params: {0}'
     code = 240
+
+
+@final
+class TooManyMatchSubjectsViolation(ASTViolation):
+    """
+    Forbids to have too many subjects in ``match`` statements.
+
+    Reasoning:
+        Too many subjects in a ``match`` statement make the code
+        difficult to read and maintain. It indicates that the logic
+        could be simplified or broken down into smaller components.
+
+    Solution:
+        Refactor the ``match`` statement to reduce the number of subjects.
+        Consider splitting the logic into multiple ``match`` statements
+        or functions to improve clarity and maintainability.
+
+    Configuration:
+        This rule is configurable with ``--max-match-subjects``.
+        Default:
+        :str:`wemake_python_styleguide.options.defaults.MAX_MATCH_SUBJECTS`
+
+    .. versionadded:: 1.0.0
+
+    """
+
+    error_template = 'Found too many subjects in `match` statement: {0}'
+    code = 241
diff --git a/wemake_python_styleguide/visitors/ast/compares.py b/wemake_python_styleguide/visitors/ast/compares.py
index 48113740..084936ce 100644
--- a/wemake_python_styleguide/visitors/ast/compares.py
+++ b/wemake_python_styleguide/visitors/ast/compares.py
@@ -10,6 +10,7 @@ from wemake_python_styleguide.logic.tree import (
     compares,
     functions,
     operators,
+    pattern_matching,
 )
 from wemake_python_styleguide.logic.walrus import get_assigned_expr
 from wemake_python_styleguide.types import AnyIf, AnyNodes
@@ -202,17 +203,27 @@ class WrongConditionalVisitor(BaseNodeVisitor):
 
     def visit_Match(self, node: ast.Match) -> None:
         """Ensures that ``match`` nodes are using valid conditionals."""
-        self._check_constant_condition(node.subject)
+        self._check_constant_condition(node.subject, is_match=True)
         self.generic_visit(node)
 
-    def _check_constant_condition(self, node: ast.AST) -> None:
+    def _check_constant_condition(
+        self,
+        node: ast.AST,
+        *,
+        is_match: bool = False,
+    ) -> None:
         if isinstance(node, ast.BoolOp):
             for condition in node.values:
                 self._check_constant_condition(condition)
         else:
             real_node = operators.unwrap_unary_node(get_assigned_expr(node))
-            if isinstance(real_node, self._forbidden_nodes):
-                self.add_violation(ConstantConditionViolation(node))
+            if is_match and not pattern_matching.is_constant_subject(real_node):
+                return
+            if not is_match and not isinstance(
+                real_node, self._forbidden_nodes
+            ):
+                return
+            self.add_violation(ConstantConditionViolation(node))
 
     def _check_nested_ifexpr(self, node: AnyIf) -> None:
         is_nested_in_if = bool(
diff --git a/wemake_python_styleguide/visitors/ast/complexity/pm.py b/wemake_python_styleguide/visitors/ast/complexity/pm.py
new file mode 100644
index 00000000..bd2076fa
--- /dev/null
+++ b/wemake_python_styleguide/visitors/ast/complexity/pm.py
@@ -0,0 +1,28 @@
+import ast
+from typing import final
+
+from wemake_python_styleguide.violations import complexity
+from wemake_python_styleguide.visitors.base import BaseNodeVisitor
+
+
+@final
+class MatchSubjectsVisitor(BaseNodeVisitor):
+    """Finds excessive match subjects in `match` statements."""
+
+    def visit_Match(self, node: ast.Match) -> None:
+        """Finds all `match` statements and checks their subjects."""
+        self._check_match_subjects_count(node)
+        self.generic_visit(node)
+
+    def _check_match_subjects_count(self, node: ast.Match) -> None:
+        if not isinstance(node.subject, ast.Tuple):
+            return
+        if len(node.subject.elts) <= self.options.max_match_subjects:
+            return
+        self.add_violation(
+            complexity.TooManyMatchSubjectsViolation(
+                node,
+                text=str(len(node.subject.elts)),
+                baseline=self.options.max_match_subjects,
+            )
+        )


Test patch:
diff --git a/tests/fixtures/noqa/noqa.py b/tests/fixtures/noqa/noqa.py
index f4100082..60e4264d 100644
--- a/tests/fixtures/noqa/noqa.py
+++ b/tests/fixtures/noqa/noqa.py
@@ -631,6 +631,11 @@ except (TypeError, ValueError, LookupError, KeyboardInterrupt):  # noqa: WPS239
     my_print('except')
 
 
+match inst1, inst2, inst3, inst4, inst5, inst6, inst7, inst8:  # noqa: WPS241
+    case 1:
+        my_print('except')
+
+
 my_print("""
 text
 """)  # noqa: WPS462
diff --git a/tests/test_checker/test_noqa.py b/tests/test_checker/test_noqa.py
index 781f695f..b5a7437a 100644
--- a/tests/test_checker/test_noqa.py
+++ b/tests/test_checker/test_noqa.py
@@ -92,6 +92,7 @@ SHOULD_BE_RAISED = types.MappingProxyType(
         'WPS238': 1,
         'WPS239': 1,
         'WPS240': 0,  # only triggers on 3.12+
+        'WPS241': 1,
         'WPS300': 1,
         'WPS301': 1,
         'WPS302': 0,  # disabled since 1.0.0
diff --git a/tests/test_logic/test_complexity/test_cognitive/test_cognitive_complexity.py b/tests/test_logic/test_complexity/test_cognitive/test_cognitive_complexity.py
index 6331fbe4..b7ae9cb7 100644
--- a/tests/test_logic/test_complexity/test_cognitive/test_cognitive_complexity.py
+++ b/tests/test_logic/test_complexity/test_cognitive/test_cognitive_complexity.py
@@ -23,6 +23,12 @@ class Test:
         return a * self.f(a - 1)  # +1 for recursion
 """
 
+complexity1_4 = """
+def f(a):
+    match a:
+        case _: return a # +1
+"""
+
 complexity2_1 = """
 def f(a, b):
     if a and b and True:  # +2
@@ -37,6 +43,13 @@ def f(a, b):
         return 2
 """
 
+complexity2_3 = """
+def f(a, b):
+    match a:
+        case 1: return 1  # +1
+        case 2: return 2  # +1
+"""
+
 complexity3_1 = """
 def f(a, b):
     if a and b or True:  # +3
@@ -112,6 +125,15 @@ def f(a):
             break  # +2
 """
 
+complexity9_2 = """
+def f(a):
+    for a in range(10):  # +1
+        match a:  # +2
+            case 1: continue  # +3
+            case 2: break  # +3
+"""
+
+
 complexity10_1 = """
 def process_raw_constant(constant, min_word_length):
     processed_words = []
@@ -150,8 +172,10 @@ def enhance(tree):
         (complexity1_1, 1),
         (complexity1_2, 1),
         (complexity1_3, 1),
+        (complexity1_4, 1),
         (complexity2_1, 2),
         (complexity2_2, 2),
+        (complexity2_3, 2),
         (complexity3_1, 3),
         (complexity3_2, 3),
         (complexity3_3, 3),
@@ -161,6 +185,7 @@ def enhance(tree):
         (complexity5_1, 5),
         (complexity6_1, 6),
         (complexity9_1, 9),
+        (complexity9_2, 9),
         (complexity10_1, 10),
         (complexity14_1, 14),
     ],
diff --git a/tests/test_visitors/test_ast/test_compares/test_conditionals.py b/tests/test_visitors/test_ast/test_compares/test_conditionals.py
index e828c929..79f9fc54 100644
--- a/tests/test_visitors/test_ast/test_compares/test_conditionals.py
+++ b/tests/test_visitors/test_ast/test_compares/test_conditionals.py
@@ -7,11 +7,6 @@ from wemake_python_styleguide.visitors.ast.compares import (
     WrongConditionalVisitor,
 )
 
-create_variable = """
-variable = 1
-{0}
-"""
-
 if_statement = 'if {0}: ...'
 ternary = 'ternary = 0 if {0} else 1'
 
@@ -83,9 +78,7 @@ def test_valid_conditional(
     mode,
 ):
     """Testing that conditionals work well."""
-    tree = parse_ast_tree(
-        mode(create_variable.format(code.format(comparators))),
-    )
+    tree = parse_ast_tree(mode(code.format(comparators)))
 
     visitor = WrongConditionalVisitor(default_options, tree=tree)
     visitor.run()
@@ -102,7 +95,6 @@ def test_valid_conditional(
         set_comprehension,
         dict_comprehension,
         gen_comprehension,
-        match_statement,
     ],
 )
 @pytest.mark.parametrize(
@@ -120,7 +112,9 @@ def test_valid_conditional(
         '{test : "1"}',
         '{"set"}',
         '("tuple",)',
-        '["list"]',
+        '[]',
+        '[variable]',
+        '(1, var)',
         'variable or False',
         'variable and False',
         'variable or True',
@@ -139,11 +133,85 @@ def test_constant_condition(
     mode,
 ):
     """Testing that violations are when using invalid conditional."""
+    tree = parse_ast_tree(mode(code.format(comparators)))
+
+    visitor = WrongConditionalVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [ConstantConditionViolation])
+
+
+@pytest.mark.parametrize(
+    'comparators',
+    [
+        'True',
+        'False',
+        'None',
+        '4',
+        '-4.8',
+        '--0.0',
+        '"test"',
+        "b'bytes'",
+        '("string in brackets")',
+        '{1 : "1"}',
+        '{"set"}',
+        '("tuple",)',
+        '[]',
+        '[1, 2]',
+        '(1, 2)',
+        'variable or False',
+        'variable and False',
+        'variable or True',
+        'variable and True',
+        '(unique := True)',
+        '(unique := -1)',
+        '...',
+    ],
+)
+def test_constant_condition_in_match(
+    assert_errors,
+    parse_ast_tree,
+    comparators,
+    default_options,
+):
+    """Testing that violations are when using invalid conditional in PM."""
     tree = parse_ast_tree(
-        mode(create_variable.format(code.format(comparators))),
+        match_statement.format(comparators),
     )
 
     visitor = WrongConditionalVisitor(default_options, tree=tree)
     visitor.run()
 
     assert_errors(visitor, [ConstantConditionViolation])
+
+
+@pytest.mark.parametrize(
+    'comparators',
+    [
+        'variable',
+        '(x := y)',
+        '-number',
+        '[x, y]',
+        '{test : "1"}',
+        '{test}',
+        '(x, y)',
+        '{**keys, "data": None}',
+        'variable or other',
+        'variable and other',
+    ],
+)
+def test_regular_condition_in_match(
+    assert_errors,
+    parse_ast_tree,
+    comparators,
+    default_options,
+):
+    """Testing correct conditional in PM."""
+    tree = parse_ast_tree(
+        match_statement.format(comparators),
+    )
+
+    visitor = WrongConditionalVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])
diff --git a/tests/test_visitors/test_ast/test_complexity/test_pm/test_match_subjects.py b/tests/test_visitors/test_ast/test_complexity/test_pm/test_match_subjects.py
new file mode 100644
index 00000000..4374fb4d
--- /dev/null
+++ b/tests/test_visitors/test_ast/test_complexity/test_pm/test_match_subjects.py
@@ -0,0 +1,90 @@
+import pytest
+
+from wemake_python_styleguide.violations.complexity import (
+    TooManyMatchSubjectsViolation,
+)
+from wemake_python_styleguide.visitors.ast.complexity.pm import (
+    MatchSubjectsVisitor,
+)
+
+match_call_expr = """
+match some_call():
+    case 1: ...
+"""
+
+match_case_long_tuple = """
+match some_call['a']:
+    case (a, b, c, d, e, f, g, h, i): ...
+"""
+
+match_subjects8 = """
+match a1, b2, c3, d4, e5, f6, g7, h8:
+    case 1: ...
+"""
+
+match_subjects7 = """
+match a1, b2, c3, d4, e5, f6, g7:
+    case 1: ...
+"""
+
+
+def test_match_subjects_wrong_count(
+    assert_errors,
+    assert_error_text,
+    parse_ast_tree,
+    default_options,
+):
+    """Testing that default settings raise a warning."""
+    tree = parse_ast_tree(match_subjects8)
+
+    visitor = MatchSubjectsVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [TooManyMatchSubjectsViolation])
+    assert_error_text(visitor, '8', baseline=default_options.max_match_subjects)
+
+
+@pytest.mark.parametrize(
+    'code',
+    [
+        match_subjects7,
+        match_call_expr,
+        match_case_long_tuple,
+    ],
+)
+def test_match_subjects_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 = MatchSubjectsVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])
+
+
+@pytest.mark.parametrize(
+    'code',
+    [
+        match_subjects8,
+        match_subjects7,
+    ],
+)
+def test_match_subjects_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_subjects=1)
+    visitor = MatchSubjectsVisitor(option_values, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [TooManyMatchSubjectsViolation])