Repository: sqlfluff/sqlfluff

Issue:
Add "enable" and "disable" syntax to noqa to allow rules disabling across multiple lines
See the `pylint` docs for an example: https://docs.pylint.org/en/1.6.0/faq.html#is-it-possible-to-locally-disable-a-particular-message


Discussion:

Patch:
diff --git a/src/sqlfluff/core/errors.py b/src/sqlfluff/core/errors.py
--- a/src/sqlfluff/core/errors.py
+++ b/src/sqlfluff/core/errors.py
@@ -82,9 +82,9 @@ def pos_marker(self):
         """Get the position marker of the violation.
 
         Returns:
-            The :obj:`PosMarker` of the segments if the violation has a segment,
-            the :obj:`PosMarker` directly stored in a `pos` attribute or None
-            if neither a present.
+            The :obj:`FilePositionMarker` of the segments if the violation has a segment,
+            the :obj:`FilePositionMarker` directly stored in a `pos` attribute or None
+            if neither is present.
 
         """
         if hasattr(self, "segment"):
diff --git a/src/sqlfluff/core/linter.py b/src/sqlfluff/core/linter.py
--- a/src/sqlfluff/core/linter.py
+++ b/src/sqlfluff/core/linter.py
@@ -53,6 +53,14 @@ class RuleTuple(NamedTuple):
     description: str
 
 
+class NoQaDirective(NamedTuple):
+    """Parsed version of a 'noqa' comment."""
+
+    line_no: int  # Source line number
+    rules: Optional[Tuple[str, ...]]  # Affected rule names
+    action: Optional[str]  # "enable", "disable", or "None"
+
+
 class ProtoFile(NamedTuple):
     """Proto object to be inherited by LintedFile."""
 
@@ -60,7 +68,7 @@ class ProtoFile(NamedTuple):
     violations: list
     time_dict: dict
     tree: Any
-    ignore_mask: list
+    ignore_mask: List[NoQaDirective]
 
 
 class ParsedString(NamedTuple):
@@ -95,10 +103,10 @@ class LintedFile(NamedTuple):
     """A class to store the idea of a linted file."""
 
     path: str
-    violations: list
+    violations: List[SQLBaseError]
     time_dict: dict
     tree: Optional[BaseSegment]
-    ignore_mask: list
+    ignore_mask: List[NoQaDirective]
     templated_file: TemplatedFile
 
     def check_tuples(self) -> List[CheckTuple]:
@@ -152,15 +160,90 @@ def get_violations(
             violations = [v for v in violations if not v.ignore]
             # Ignore any rules in the ignore mask
             if self.ignore_mask:
-                for line_no, rules in self.ignore_mask:
-                    violations = [
-                        v
-                        for v in violations
-                        if not (
-                            v.line_no() == line_no
-                            and (rules is None or v.rule_code() in rules)
-                        )
-                    ]
+                violations = self._ignore_masked_violations(violations)
+        return violations
+
+    @staticmethod
+    def _ignore_masked_violations_single_line(
+        violations: List[SQLBaseError], ignore_mask: List[NoQaDirective]
+    ):
+        """Returns whether to ignore error for line-specific directives.
+
+        The "ignore" list is assumed to ONLY contain NoQaDirectives with
+        action=None.
+        """
+        for ignore in ignore_mask:
+            violations = [
+                v
+                for v in violations
+                if not (
+                    v.line_no() == ignore.line_no
+                    and (ignore.rules is None or v.rule_code() in ignore.rules)
+                )
+            ]
+        return violations
+
+    @staticmethod
+    def _should_ignore_violation_line_range(
+        line_no: int, ignore_rule: List[NoQaDirective]
+    ):
+        """Returns whether to ignore a violation at line_no."""
+        # Loop through the NoQaDirectives to find the state of things at
+        # line_no. Assumptions about "ignore_rule":
+        # - Contains directives for only ONE RULE, i.e. the rule that was
+        #   violated at line_no
+        # - Sorted in ascending order by line number
+        disable = False
+        for ignore in ignore_rule:
+            if ignore.line_no > line_no:
+                break
+            disable = ignore.action == "disable"
+        return disable
+
+    @classmethod
+    def _ignore_masked_violations_line_range(
+        cls, violations: List[SQLBaseError], ignore_mask: List[NoQaDirective]
+    ):
+        """Returns whether to ignore error for line-range directives.
+
+        The "ignore" list is assumed to ONLY contain NoQaDirectives where
+        action is "enable" or "disable".
+        """
+        result = []
+        for v in violations:
+            # Find the directives that affect the violated rule "v", either
+            # because they specifically reference it or because they don't
+            # specify a list of rules, thus affecting ALL rules.
+            ignore_rule = sorted(
+                [
+                    ignore
+                    for ignore in ignore_mask
+                    if not ignore.rules
+                    or (v.rule_code() in cast(Tuple[str, ...], ignore.rules))
+                ],
+                key=lambda ignore: ignore.line_no,
+            )
+            # Determine whether to ignore the violation, based on the relevant
+            # enable/disable directives.
+            if not cls._should_ignore_violation_line_range(v.line_no(), ignore_rule):
+                result.append(v)
+        return result
+
+    def _ignore_masked_violations(
+        self, violations: List[SQLBaseError]
+    ) -> List[SQLBaseError]:
+        """Remove any violations specified by ignore_mask.
+
+        This involves two steps:
+        1. Filter out violations affected by single-line "noqa" directives.
+        2. Filter out violations affected by disable/enable "noqa" directives.
+        """
+        ignore_specific = [ignore for ignore in self.ignore_mask if not ignore.action]
+        ignore_range = [ignore for ignore in self.ignore_mask if ignore.action]
+        violations = self._ignore_masked_violations_single_line(
+            violations, ignore_specific
+        )
+        violations = self._ignore_masked_violations_line_range(violations, ignore_range)
         return violations
 
     def num_violations(self, **kwargs) -> int:
@@ -907,27 +990,53 @@ def parse_string(
         bencher("Finish parsing {0!r}".format(short_fname))
         return ParsedString(parsed, violations, time_dict, templated_file, config)
 
-    @staticmethod
-    def extract_ignore_from_comment(comment: RawSegment):
-        """Extract ignore mask entries from a comment segment."""
+    @classmethod
+    def parse_noqa(cls, comment: str, line_no: int):
+        """Extract ignore mask entries from a comment string."""
         # Also trim any whitespace afterward
-        comment_content = comment.raw_trimmed().strip()
-        if comment_content.startswith("noqa"):
+        if comment.startswith("noqa"):
             # This is an ignore identifier
-            comment_remainder = comment_content[4:]
+            comment_remainder = comment[4:]
             if comment_remainder:
                 if not comment_remainder.startswith(":"):
                     return SQLParseError(
                         "Malformed 'noqa' section. Expected 'noqa: <rule>[,...]",
-                        segment=comment,
                     )
-                comment_remainder = comment_remainder[1:]
-                rules = [r.strip() for r in comment_remainder.split(",")]
-                return (comment.pos_marker.line_no, tuple(rules))
-            else:
-                return (comment.pos_marker.line_no, None)
+                comment_remainder = comment_remainder[1:].strip()
+                if comment_remainder:
+                    action: Optional[str]
+                    if "=" in comment_remainder:
+                        action, rule_part = comment_remainder.split("=", 1)
+                        if action not in {"disable", "enable"}:
+                            return SQLParseError(
+                                "Malformed 'noqa' section. Expected 'noqa: enable=<rule>[,...] | all' or 'noqa: disable=<rule>[,...] | all",
+                            )
+                    else:
+                        action = None
+                        rule_part = comment_remainder
+                        if rule_part in {"disable", "enable"}:
+                            return SQLParseError(
+                                "Malformed 'noqa' section. Expected 'noqa: enable=<rule>[,...] | all' or 'noqa: disable=<rule>[,...] | all",
+                            )
+                    rules: Optional[Tuple[str, ...]]
+                    if rule_part != "all":
+                        rules = tuple(r.strip() for r in rule_part.split(","))
+                    else:
+                        rules = None
+                    return NoQaDirective(line_no, rules, action)
+            return NoQaDirective(line_no, None, None)
         return None
 
+    @classmethod
+    def extract_ignore_from_comment(cls, comment: RawSegment):
+        """Extract ignore mask entries from a comment segment."""
+        # Also trim any whitespace afterward
+        comment_content = comment.raw_trimmed().strip()
+        result = cls.parse_noqa(comment_content, comment.pos_marker.line_no)
+        if isinstance(result, SQLParseError):
+            result.segment = comment
+        return result
+
     @staticmethod
     def _warn_unfixable(code: str):
         linter_logger.warning(


Test patch:
diff --git a/test/core/linter_test.py b/test/core/linter_test.py
--- a/test/core/linter_test.py
+++ b/test/core/linter_test.py
@@ -1,11 +1,23 @@
 """The Test file for the linter class."""
 
 import pytest
+from typing import List
 from unittest.mock import patch
 
 from sqlfluff.core import Linter, FluffConfig
-from sqlfluff.core.errors import SQLLintError, SQLParseError
-from sqlfluff.core.linter import LintingResult
+from sqlfluff.core.errors import SQLBaseError, SQLLintError, SQLParseError
+from sqlfluff.core.linter import LintingResult, NoQaDirective
+import sqlfluff.core.linter as linter
+from sqlfluff.core.parser import FilePositionMarker
+
+
+class DummyLintError(SQLBaseError):
+    """Fake lint error used by tests, similar to SQLLintError."""
+
+    def __init__(self, pos: FilePositionMarker, code: str = "L001"):
+        self.pos = pos
+        self._code = code
+        super(DummyLintError, self).__init__()
 
 
 def normalise_paths(paths):
@@ -193,7 +205,7 @@ def test__linter__linting_unexpected_error_handled_gracefully(
     """Test that an unexpected internal error is handled gracefully and returns the issue-surfacing file."""
     patched_lint_string.side_effect = Exception("Something unexpected happened")
     lntr = Linter()
-    lntr.lint_paths(["test/fixtures/linter/passing.sql"])
+    lntr.lint_paths(("test/fixtures/linter/passing.sql",))
     assert (
         "Unable to lint test/fixtures/linter/passing.sql due to an internal error."
         # NB: Replace is to handle windows-style paths.
@@ -249,3 +261,240 @@ def test__linter__mask_templated_violations(ignore_templated_areas, check_tuples
     )
     linted = lntr.lint_path(path="test/fixtures/templater/jinja_h_macros/jinja.sql")
     assert linted.check_tuples() == check_tuples
+
+
+@pytest.mark.parametrize(
+    "input,expected",
+    [
+        ("", None),
+        ("noqa", NoQaDirective(0, None, None)),
+        ("noqa?", SQLParseError),
+        ("noqa:", NoQaDirective(0, None, None)),
+        ("noqa:L001,L002", NoQaDirective(0, ("L001", "L002"), None)),
+        ("noqa: enable=L005", NoQaDirective(0, ("L005",), "enable")),
+        ("noqa: disable=L010", NoQaDirective(0, ("L010",), "disable")),
+        ("noqa: disable=all", NoQaDirective(0, None, "disable")),
+        ("noqa: disable", SQLParseError),
+    ],
+)
+def test_parse_noqa(input, expected):
+    """Test correct of "noqa" comments."""
+    result = Linter.parse_noqa(input, 0)
+    if not isinstance(expected, type):
+        assert result == expected
+    else:
+        # With exceptions, just check the type, not the contents.
+        assert isinstance(result, expected)
+
+
+@pytest.mark.parametrize(
+    "noqa,violations,expected",
+    [
+        [
+            [],
+            [DummyLintError(FilePositionMarker(statement_index=None, line_no=1))],
+            [
+                0,
+            ],
+        ],
+        [
+            [dict(comment="noqa: L001", line_no=1)],
+            [DummyLintError(FilePositionMarker(statement_index=None, line_no=1))],
+            [],
+        ],
+        [
+            [dict(comment="noqa: L001", line_no=2)],
+            [DummyLintError(FilePositionMarker(statement_index=None, line_no=1))],
+            [0],
+        ],
+        [
+            [dict(comment="noqa: L002", line_no=1)],
+            [DummyLintError(FilePositionMarker(statement_index=None, line_no=1))],
+            [0],
+        ],
+        [
+            [dict(comment="noqa: enable=L001", line_no=1)],
+            [DummyLintError(FilePositionMarker(statement_index=None, line_no=1))],
+            [0],
+        ],
+        [
+            [dict(comment="noqa: disable=L001", line_no=1)],
+            [DummyLintError(FilePositionMarker(statement_index=None, line_no=1))],
+            [],
+        ],
+        [
+            [
+                dict(comment="noqa: disable=L001", line_no=2),
+                dict(comment="noqa: enable=L001", line_no=4),
+            ],
+            [DummyLintError(FilePositionMarker(statement_index=None, line_no=1))],
+            [0],
+        ],
+        [
+            [
+                dict(comment="noqa: disable=L001", line_no=2),
+                dict(comment="noqa: enable=L001", line_no=4),
+            ],
+            [DummyLintError(FilePositionMarker(statement_index=None, line_no=2))],
+            [],
+        ],
+        [
+            [
+                dict(comment="noqa: disable=L001", line_no=2),
+                dict(comment="noqa: enable=L001", line_no=4),
+            ],
+            [DummyLintError(FilePositionMarker(statement_index=None, line_no=3))],
+            [],
+        ],
+        [
+            [
+                dict(comment="noqa: disable=L001", line_no=2),
+                dict(comment="noqa: enable=L001", line_no=4),
+            ],
+            [DummyLintError(FilePositionMarker(statement_index=None, line_no=4))],
+            [0],
+        ],
+        [
+            [
+                dict(comment="noqa: disable=all", line_no=2),
+                dict(comment="noqa: enable=all", line_no=4),
+            ],
+            [DummyLintError(FilePositionMarker(statement_index=None, line_no=1))],
+            [0],
+        ],
+        [
+            [
+                dict(comment="noqa: disable=all", line_no=2),
+                dict(comment="noqa: enable=all", line_no=4),
+            ],
+            [DummyLintError(FilePositionMarker(statement_index=None, line_no=2))],
+            [],
+        ],
+        [
+            [
+                dict(comment="noqa: disable=all", line_no=2),
+                dict(comment="noqa: enable=all", line_no=4),
+            ],
+            [DummyLintError(FilePositionMarker(statement_index=None, line_no=3))],
+            [],
+        ],
+        [
+            [
+                dict(comment="noqa: disable=all", line_no=2),
+                dict(comment="noqa: enable=all", line_no=4),
+            ],
+            [DummyLintError(FilePositionMarker(statement_index=None, line_no=4))],
+            [0],
+        ],
+        [
+            [
+                dict(comment="noqa: disable=L001", line_no=2),
+                dict(comment="noqa: enable=all", line_no=4),
+            ],
+            [
+                DummyLintError(
+                    FilePositionMarker(statement_index=None, line_no=2), code="L001"
+                ),
+                DummyLintError(
+                    FilePositionMarker(statement_index=None, line_no=2), code="L002"
+                ),
+                DummyLintError(
+                    FilePositionMarker(statement_index=None, line_no=4), code="L001"
+                ),
+                DummyLintError(
+                    FilePositionMarker(statement_index=None, line_no=4), code="L002"
+                ),
+            ],
+            [1, 2, 3],
+        ],
+        [
+            [
+                dict(comment="noqa: disable=all", line_no=2),
+                dict(comment="noqa: enable=L001", line_no=4),
+            ],
+            [
+                DummyLintError(
+                    FilePositionMarker(statement_index=None, line_no=2), code="L001"
+                ),
+                DummyLintError(
+                    FilePositionMarker(statement_index=None, line_no=2), code="L002"
+                ),
+                DummyLintError(
+                    FilePositionMarker(statement_index=None, line_no=4), code="L001"
+                ),
+                DummyLintError(
+                    FilePositionMarker(statement_index=None, line_no=4), code="L002"
+                ),
+            ],
+            [2],
+        ],
+    ],
+    ids=[
+        "1_violation_no_ignore",
+        "1_violation_ignore_specific_line",
+        "1_violation_ignore_different_specific_line",
+        "1_violation_ignore_different_specific_rule",
+        "1_violation_ignore_enable_this_range",
+        "1_violation_ignore_disable_this_range",
+        "1_violation_line_1_ignore_disable_specific_2_3",
+        "1_violation_line_2_ignore_disable_specific_2_3",
+        "1_violation_line_3_ignore_disable_specific_2_3",
+        "1_violation_line_4_ignore_disable_specific_2_3",
+        "1_violation_line_1_ignore_disable_all_2_3",
+        "1_violation_line_2_ignore_disable_all_2_3",
+        "1_violation_line_3_ignore_disable_all_2_3",
+        "1_violation_line_4_ignore_disable_all_2_3",
+        "4_violations_two_types_disable_specific_enable_all",
+        "4_violations_two_types_disable_all_enable_specific",
+    ],
+)
+def test_linted_file_ignore_masked_violations(
+    noqa: dict, violations: List[SQLBaseError], expected
+):
+    """Test that _ignore_masked_violations() correctly filters violations."""
+    ignore_mask = [Linter.parse_noqa(**c) for c in noqa]
+    lf = linter.LintedFile(
+        path="",
+        violations=violations,
+        time_dict={},
+        tree=None,
+        ignore_mask=ignore_mask,
+        templated_file=linter.TemplatedFile(""),
+    )
+    result = lf._ignore_masked_violations(violations)
+    expected_violations = [v for i, v in enumerate(violations) if i in expected]
+    assert expected_violations == result
+
+
+def test_linter_noqa():
+    """Test "noqa" feature at the higher "Linter" level."""
+    lntr = Linter(
+        config=FluffConfig(
+            overrides={
+                "rules": "L012",
+            }
+        )
+    )
+    sql = """
+    SELECT
+        col_a a,
+        col_b b, --noqa: disable=L012
+        col_c c,
+        col_d d, --noqa: enable=L012
+        col_e e,
+        col_f f,
+        col_g g,  --noqa
+        col_h h,
+        col_i i, --noqa:L012
+        col_j j,
+        col_k k, --noqa:L013
+        col_l l,
+        col_m m,
+        col_n n, --noqa: disable=all
+        col_o o,
+        col_p p --noqa: enable=all
+    FROM foo
+        """
+    result = lntr.lint_string(sql)
+    violations = result.get_violations()
+    assert {3, 6, 7, 8, 10, 12, 13, 14, 15, 18} == {v.line_no() for v in violations}