Repository: wemake-services/wemake-python-styleguide

Issue:
Forbid `for` loops with unused `else`
# Rule request

<!-- Thanks for coming up with a proposal! -->

## Thesis

We can tell when `else` blocks are unused: to be used there should be at least one `break` statement inside a loop's body.

## Reasoning

This rule will reduce complexity, improve readability, and protect for possible errors.



Discussion:
@sobolevn hi, can i take this issue?
@novikovfred sure!

Resolving patch:
diff --git a/wemake_python_styleguide/violations/best_practices.py b/wemake_python_styleguide/violations/best_practices.py
--- a/wemake_python_styleguide/violations/best_practices.py
+++ b/wemake_python_styleguide/violations/best_practices.py
@@ -36,6 +36,7 @@
    StaticMethodViolation
    BadMagicMethodViolation
    NestedImportViolation
+   RedundantForElseViolation
 
 Comments
 --------
@@ -66,6 +67,7 @@
 .. autoclass:: StaticMethodViolation
 .. autoclass:: BadMagicMethodViolation
 .. autoclass:: NestedImportViolation
+.. autoclass:: RedundantForElseViolation
 
 """
 
@@ -591,3 +593,25 @@ def some():
     #: Error message shown to the user.
     error_template = 'Found nested import "{0}"'
     code = 435
+
+
+class RedundantForElseViolation(ASTViolation):
+    """
+    Forbids to use ``else`` with ``break`` in ``for`` loop.
+
+    Reasoning:
+        This rule will reduce complexity, improve readability,
+        and protect from possible errors.
+
+    Solution:
+        Refactor your ``else`` case logic
+        to be inside the ``for`` body.
+
+    Note:
+        Returns Z436 as error code
+
+    """
+
+    #: Error message shown to the user.
+    error_template = 'Found `else` in `for` loop with `break`'
+    code = 436
diff --git a/wemake_python_styleguide/visitors/ast/keywords.py b/wemake_python_styleguide/visitors/ast/keywords.py
--- a/wemake_python_styleguide/visitors/ast/keywords.py
+++ b/wemake_python_styleguide/visitors/ast/keywords.py
@@ -6,6 +6,7 @@
 from wemake_python_styleguide.types import AnyNodes
 from wemake_python_styleguide.violations.best_practices import (
     RaiseNotImplementedViolation,
+    RedundantForElseViolation,
     WrongKeywordViolation,
 )
 from wemake_python_styleguide.violations.consistency import (
@@ -88,3 +89,22 @@ def visit_comprehension(self, node: ast.comprehension) -> None:
         """
         self._check_ifs(node)
         self.generic_visit(node)
+
+
+class WrongForElseVisitor(BaseNodeVisitor):
+    """Responsible for restricting else in for loops with break."""
+
+    def _check_for_needs_else(self, node: ast.For) -> None:
+        break_in_for_loop = False
+
+        for condition in ast.walk(node):
+            if isinstance(condition, ast.Break):
+                break_in_for_loop = True
+
+        if node.orelse and break_in_for_loop:
+            self.add_violation(RedundantForElseViolation(node=node))
+
+    def visit_For(self, node: ast.For) -> None:
+        """Used for find else block in for loops with break."""
+        self._check_for_needs_else(node)
+        self.generic_visit(node)
diff --git a/wemake_python_styleguide/visitors/presets/general.py b/wemake_python_styleguide/visitors/presets/general.py
--- a/wemake_python_styleguide/visitors/presets/general.py
+++ b/wemake_python_styleguide/visitors/presets/general.py
@@ -25,6 +25,7 @@
     keywords.WrongRaiseVisitor,
     keywords.WrongKeywordVisitor,
     keywords.WrongListComprehensionVisitor,
+    keywords.WrongForElseVisitor,
 
     WrongFunctionCallVisitor,
     WrongImportVisitor,


Test patch:
diff --git a/tests/fixtures/noqa.py b/tests/fixtures/noqa.py
--- a/tests/fixtures/noqa.py
+++ b/tests/fixtures/noqa.py
@@ -59,3 +59,9 @@ class Nested:  # noqa: Z306,Z431
 
 assert 1 > 1 > hex_number  # noqa: Z308
 assert 2 > octal_number  # noqa: Z309
+
+
+for symbol in 'abc':  # noqa: Z436
+    break
+else:
+    ...
diff --git a/tests/test_checkers/test_noqa.py b/tests/test_checkers/test_noqa.py
--- a/tests/test_checkers/test_noqa.py
+++ b/tests/test_checkers/test_noqa.py
@@ -49,6 +49,7 @@ def test_noqa_fixture_disabled(absolute_path):
         'Z433': 2,
         'Z434': 1,
         'Z435': 1,
+        'Z436': 1,
     }
 
     for error in counts:
diff --git a/tests/test_visitors/test_ast/test_keywords/test_loops.py b/tests/test_visitors/test_ast/test_keywords/test_loops.py
new file mode 100644
--- /dev/null
+++ b/tests/test_visitors/test_ast/test_keywords/test_loops.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+
+import pytest
+
+from wemake_python_styleguide.violations.best_practices import (
+    RedundantForElseViolation,
+)
+from wemake_python_styleguide.visitors.ast.keywords import WrongForElseVisitor
+
+right_else_in_for_loop = """
+for x in '123':
+    ...
+else:
+    ...
+"""
+
+wrong_else_in_for_loop = """
+for x in '123':
+    break
+else:
+   ...
+"""
+
+check_nested_if_else = """
+for x in '123':
+    if x:
+        ...
+    else:
+        ...
+"""
+
+
+@pytest.mark.parametrize('code', [
+    wrong_else_in_for_loop,
+])
+def test_wrong_else_in_for_loop(
+    assert_errors, parse_ast_tree, code, default_options,
+):
+    """Violations are raised when else with break statement."""
+    tree = parse_ast_tree(code)
+
+    visitor = WrongForElseVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [RedundantForElseViolation])
+
+
+@pytest.mark.parametrize('code', [
+    right_else_in_for_loop,
+    check_nested_if_else,
+])
+def test_correct_else_in_for_loop(
+    assert_errors, parse_ast_tree, code, default_options,
+):
+    """Violations are not raised when else without break statement."""
+    tree = parse_ast_tree(code)
+
+    visitor = WrongForElseVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])