# pylint: disable=missing-function-docstring,line-too-long
# pylint: disable=too-many-lines,attribute-defined-outside-init

"""Test for diff_cover.violations_reporter"""

import os
import subprocess
import xml.etree.ElementTree as etree
from io import BytesIO, StringIO
from subprocess import Popen
from textwrap import dedent

import pytest

from diff_cover.command_runner import CommandError, run_command_for_code
from diff_cover.violationsreporters import base
from diff_cover.violationsreporters.base import QualityReporter
from diff_cover.violationsreporters.violations_reporter import (
    ClangFormatDriver,
    CppcheckDriver,
    EslintDriver,
    LcovCoverageReporter,
    PylintDriver,
    Violation,
    XmlCoverageReporter,
    flake8_driver,
    jshint_driver,
    mypy_driver,
    pycodestyle_driver,
    pydocstyle_driver,
    pyflakes_driver,
    ruff_check_driver,
    shellcheck_driver,
)


@pytest.fixture(autouse=True)
def patch_so_all_files_exist(mocker, request):
    if "disable_all_files_exist" in request.keywords:
        return
    mock = mocker.patch.object(base.os.path, "exists")
    mock.returnvalue = True


@pytest.fixture
def process_patcher(mocker):
    def _inner(return_value, status_code=0):
        mocked_process = mocker.Mock()
        mocked_process.returncode = status_code
        mocked_process.communicate.return_value = return_value
        mocked_subprocess = mocker.patch("diff_cover.command_runner.subprocess")
        popen_mock = mocked_subprocess.Popen
        popen_instance = popen_mock.return_value
        popen_instance.__enter__ = mocker.Mock(return_value=mocked_process)
        popen_instance.__exit__ = mocker.Mock(return_value=None)
        return mocked_process

    return _inner


class TestXmlCoverageReporterTest:
    MANY_VIOLATIONS = {
        Violation(3, None),
        Violation(7, None),
        Violation(11, None),
        Violation(13, None),
    }
    FEW_MEASURED = {2, 3, 5, 7, 11, 13}

    FEW_VIOLATIONS = {Violation(3, None), Violation(11, None)}
    MANY_MEASURED = {2, 3, 5, 7, 11, 13, 17}

    ONE_VIOLATION = {Violation(11, None)}
    VERY_MANY_MEASURED = {2, 3, 5, 7, 11, 13, 17, 23, 24, 25, 26, 27}

    MANY_VIOLATIONS_EXPANDED_MANY_MEASURED = {
        Violation(3, None),
        Violation(4, None),
        Violation(7, None),
        Violation(8, None),
        Violation(9, None),
        Violation(10, None),
        Violation(11, None),
        Violation(12, None),
        Violation(13, None),
        Violation(14, None),
        Violation(15, None),
        Violation(16, None),
    }

    @pytest.fixture(autouse=True)
    def patch_git_patch(self, mocker):
        # Paths generated by git_path are always the given argument
        _git_path_mock = mocker.patch(
            "diff_cover.violationsreporters.violations_reporter.GitPathTool"
        )
        _git_path_mock.relative_path = lambda path: path
        _git_path_mock.absolute_path = lambda path: path

    def test_violations(self):
        # Construct the XML report
        file_paths = ["file1.py", "subdir/file2.py"]
        violations = self.MANY_VIOLATIONS
        measured = self.FEW_MEASURED
        xml = self._coverage_xml(file_paths, violations, measured)

        # Parse the report
        coverage = XmlCoverageReporter(xml)

        # Expect that the name is set
        assert coverage.name() == "XML"

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations == coverage.violations("file1.py")
        assert measured == coverage.measured_lines("file1.py")

        # Try getting a smaller range
        result = coverage.violations("subdir/file2.py")
        assert result == violations

        # Once more on the first file (for caching)
        result = coverage.violations("file1.py")
        assert result == violations

    def test_non_python_violations(self):
        """
        Non python projects often just have a file name specified while
         the full path can be acquired from a sources tag in the XML.

         This test checks that flow by requesting violation info from a path
         that can only be constructed by using the path provided in the sources
         tag
        """
        fancy_path = "superFancyPath"
        file_paths = ["file1.java"]
        source_paths = [fancy_path]
        violations = self.MANY_VIOLATIONS
        measured = self.FEW_MEASURED

        xml = self._coverage_xml(
            file_paths, violations, measured, source_paths=source_paths
        )
        coverage = XmlCoverageReporter([xml])

        assert violations == coverage.violations(f"{fancy_path}/{file_paths[0]}")
        assert measured == coverage.measured_lines(f"{fancy_path}/{file_paths[0]}")

    def test_non_python_violations_empty_path(self):
        """
        In the wild empty sources can happen. See https://github.com/Bachmann1234/diff-cover/issues/88
        Best I can tell its mostly irrelevant but I mostly don't want it crashing
        """
        xml = etree.fromstring(
            """
        <coverage line-rate="0.178" branch-rate="0.348" version="1.9" timestamp="1545037553" lines-covered="675" lines-valid="3787" branches-covered="260" branches-valid="747">
        <sources>
        <source></source>
        </sources>
        </coverage>
        """
        )

        coverage = XmlCoverageReporter([xml])

        assert set() == coverage.violations("")
        assert set() == coverage.measured_lines("")

    def test_two_inputs_first_violate(self):
        # Construct the XML report
        file_paths = ["file1.py"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = self.FEW_VIOLATIONS

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED

        xml = self._coverage_xml(file_paths, violations1, measured1)
        xml2 = self._coverage_xml(file_paths, violations2, measured2)

        # Parse the report
        coverage = XmlCoverageReporter([xml, xml2])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 == coverage.violations("file1.py")

        assert measured1 | measured2 == coverage.measured_lines("file1.py")

    def test_two_inputs_second_violate(self):
        # Construct the XML report
        file_paths = ["file1.py"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = self.FEW_VIOLATIONS

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED

        xml = self._coverage_xml(file_paths, violations1, measured1)
        xml2 = self._coverage_xml(file_paths, violations2, measured2)

        # Parse the report
        coverage = XmlCoverageReporter([xml2, xml])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 == coverage.violations("file1.py")

        assert measured1 | measured2 == coverage.measured_lines("file1.py")

    def test_three_inputs(self):
        # Construct the XML report
        file_paths = ["file1.py"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = self.FEW_VIOLATIONS
        violations3 = self.ONE_VIOLATION

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED
        measured3 = self.VERY_MANY_MEASURED

        xml = self._coverage_xml(file_paths, violations1, measured1)
        xml2 = self._coverage_xml(file_paths, violations2, measured2)
        xml3 = self._coverage_xml(file_paths, violations3, measured3)

        # Parse the report
        coverage = XmlCoverageReporter([xml2, xml, xml3])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 & violations3 == coverage.violations(
            "file1.py"
        )

        assert measured1 | measured2 | measured3 == coverage.measured_lines("file1.py")

    def test_different_files_in_inputs(self):
        # Construct the XML report
        xml_roots = [
            self._coverage_xml(["file.py"], self.MANY_VIOLATIONS, self.FEW_MEASURED),
            self._coverage_xml(
                ["other_file.py"], self.FEW_VIOLATIONS, self.MANY_MEASURED
            ),
        ]

        # Parse the report
        coverage = XmlCoverageReporter(xml_roots)

        assert coverage.violations("file.py") == self.MANY_VIOLATIONS
        assert coverage.violations("other_file.py") == self.FEW_VIOLATIONS

    def test_empty_violations(self):
        """
        Test that an empty violations report is handled properly
        """
        # Construct the XML report
        file_paths = ["file1.py"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = set()

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED

        xml = self._coverage_xml(file_paths, violations1, measured1)
        xml2 = self._coverage_xml(file_paths, violations2, measured2)

        # Parse the report
        coverage = XmlCoverageReporter([xml2, xml])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 == coverage.violations("file1.py")

        assert measured1 | measured2 == coverage.measured_lines("file1.py")

    def test_no_such_file(self):
        # Construct the XML report with no source files
        xml = self._coverage_xml([], [], [])

        # Parse the report
        coverage = XmlCoverageReporter(xml)

        # Expect that we get no results
        result = coverage.violations("file.py")
        assert result == set()

    def test_expand_unreported_lines_when_configured(self):
        # Construct the XML report
        file_paths = ["file1.java"]
        # fixture
        violations = self.MANY_VIOLATIONS
        measured = self.MANY_MEASURED
        xml = self._coverage_xml(file_paths, violations, measured)

        # Parse the reports
        coverage = XmlCoverageReporter([xml], expand_coverage_report=True)

        # Expect that the name is set
        assert coverage.name() == "XML"

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert (
            coverage.violations("file1.java")
            == self.MANY_VIOLATIONS_EXPANDED_MANY_MEASURED
        )

    def test_expand_unreported_lines_without_violations(self):
        # Construct the XML report
        file_paths = ["file1.java"]
        # fixture
        violations = {}
        measured = self.MANY_MEASURED
        xml = self._coverage_xml(file_paths, violations, measured)

        # Parse the reports
        coverage = XmlCoverageReporter([xml], expand_coverage_report=True)

        assert set() == coverage.violations("file1.java")

    def test_expand_unreported_lines_without_measured(self):
        # Construct the XML report
        file_paths = ["file1.java"]
        # fixture
        violations = {}
        measured = {}
        xml = self._coverage_xml(file_paths, violations, measured)

        # Parse the reports
        coverage = XmlCoverageReporter([xml], expand_coverage_report=True)

        assert set() == coverage.violations("file1.java")

    def _coverage_xml(self, file_paths, violations, measured, source_paths=None):
        """
        Build an XML tree with source files specified by `file_paths`.
        Each source fill will have the same set of covered and
        uncovered lines.

        `file_paths` is a list of path strings
        `line_dict` is a dictionary with keys that are line numbers
        and values that are True/False indicating whether the line
        is covered

        This leaves out some attributes of the Cobertura format,
        but includes all the elements.
        """
        root = etree.Element("coverage")
        if source_paths:
            sources = etree.SubElement(root, "sources")
            for path in source_paths:
                source = etree.SubElement(sources, "source")
                source.text = path

        packages = etree.SubElement(root, "packages")
        classes = etree.SubElement(packages, "classes")

        violation_lines = {violation.line for violation in violations}

        for path in file_paths:
            src_node = etree.SubElement(classes, "class")
            src_node.set("filename", path)

            etree.SubElement(src_node, "methods")
            lines_node = etree.SubElement(src_node, "lines")

            # Create a node for each line in measured
            for line_num in measured:
                is_covered = line_num not in violation_lines
                line = etree.SubElement(lines_node, "line")

                hits = 1 if is_covered else 0
                line.set("hits", str(hits))
                line.set("number", str(line_num))

        return root


class TestCloverXmlCoverageReporterTest:
    MANY_VIOLATIONS = {
        Violation(3, None),
        Violation(7, None),
        Violation(11, None),
        Violation(13, None),
    }
    FEW_MEASURED = {2, 3, 5, 7, 11, 13}

    FEW_VIOLATIONS = {Violation(3, None), Violation(11, None)}
    MANY_MEASURED = {2, 3, 5, 7, 11, 13, 17}

    ONE_VIOLATION = {Violation(11, None)}
    VERY_MANY_MEASURED = {2, 3, 5, 7, 11, 13, 17, 23, 24, 25, 26, 27}

    @pytest.fixture(autouse=True)
    def patch_git_patch(self, mocker):
        # Paths generated by git_path are always the given argument
        _git_path_mock = mocker.patch(
            "diff_cover.violationsreporters.violations_reporter.GitPathTool"
        )
        _git_path_mock.relative_path = lambda path: path
        _git_path_mock.absolute_path = lambda path: path

    def test_violations(self):
        # Construct the XML report
        file_paths = ["file1.java", "subdir/file2.java"]
        violations = self.MANY_VIOLATIONS
        measured = self.FEW_MEASURED
        xml = self._coverage_xml(file_paths, violations, measured)

        # Parse the report
        coverage = XmlCoverageReporter([xml])

        # Expect that the name is set
        assert coverage.name() == "XML"

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations == coverage.violations("file1.java")
        assert measured == coverage.measured_lines("file1.java")

        # Try getting a smaller range
        result = coverage.violations("subdir/file2.java")
        assert result == violations

        # Once more on the first file (for caching)
        result = coverage.violations("file1.java")
        assert result == violations

    def test_two_inputs_first_violate(self):
        # Construct the XML report
        file_paths = ["file1.java"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = self.FEW_VIOLATIONS

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED

        xml = self._coverage_xml(file_paths, violations1, measured1)
        xml2 = self._coverage_xml(file_paths, violations2, measured2)

        # Parse the report
        coverage = XmlCoverageReporter([xml, xml2])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 == coverage.violations("file1.java")

        assert measured1 | measured2 == coverage.measured_lines("file1.java")

    def test_two_inputs_second_violate(self):
        # Construct the XML report
        file_paths = ["file1.java"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = self.FEW_VIOLATIONS

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED

        xml = self._coverage_xml(file_paths, violations1, measured1)
        xml2 = self._coverage_xml(file_paths, violations2, measured2)

        # Parse the report
        coverage = XmlCoverageReporter([xml2, xml])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 == coverage.violations("file1.java")

        assert measured1 | measured2 == coverage.measured_lines("file1.java")

    def test_three_inputs(self):
        # Construct the XML report
        file_paths = ["file1.java"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = self.FEW_VIOLATIONS
        violations3 = self.ONE_VIOLATION

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED
        measured3 = self.VERY_MANY_MEASURED

        xml = self._coverage_xml(file_paths, violations1, measured1)
        xml2 = self._coverage_xml(file_paths, violations2, measured2)
        xml3 = self._coverage_xml(file_paths, violations3, measured3)

        # Parse the report
        coverage = XmlCoverageReporter([xml2, xml, xml3])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 & violations3 == coverage.violations(
            "file1.java"
        )
        assert measured1 | measured2 | measured3 == coverage.measured_lines(
            "file1.java"
        )

    def test_different_files_in_inputs(self):
        # Construct the XML report
        xml_roots = [
            self._coverage_xml(["file.java"], self.MANY_VIOLATIONS, self.FEW_MEASURED),
            self._coverage_xml(
                ["other_file.java"], self.FEW_VIOLATIONS, self.MANY_MEASURED
            ),
        ]

        # Parse the report
        coverage = XmlCoverageReporter(xml_roots)

        assert coverage.violations("file.java") == self.MANY_VIOLATIONS
        assert coverage.violations("other_file.java") == self.FEW_VIOLATIONS

    def test_empty_violations(self):
        """
        Test that an empty violations report is handled properly
        """
        # Construct the XML report
        file_paths = ["file1.java"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = set()

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED

        xml = self._coverage_xml(file_paths, violations1, measured1)
        xml2 = self._coverage_xml(file_paths, violations2, measured2)

        # Parse the report
        coverage = XmlCoverageReporter([xml2, xml])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 == coverage.violations("file1.java")

        assert measured1 | measured2 == coverage.measured_lines("file1.java")

    def test_no_such_file(self):
        # Construct the XML report with no source files
        xml = self._coverage_xml([], [], [])

        # Parse the report
        coverage = XmlCoverageReporter(xml)

        # Expect that we get no results
        result = coverage.violations("file.java")
        assert result == set()

    def _coverage_xml(self, file_paths, violations, measured):
        """
        Build an XML tree with source files specified by `file_paths`.
        Each source fill will have the same set of covered and
        uncovered lines.

        `file_paths` is a list of path strings
        `line_dict` is a dictionary with keys that are line numbers
        and values that are True/False indicating whether the line
        is covered

        This leaves out some attributes of the Cobertura format,
        but includes all the elements.
        """
        root = etree.Element("coverage")
        root.set("clover", "4.2.0")
        project = etree.SubElement(root, "project")
        package = etree.SubElement(project, "package")

        violation_lines = {violation.line for violation in violations}

        for path in file_paths:
            src_node = etree.SubElement(package, "file")
            src_node.set("path", path)

            # Create a node for each line in measured
            for line_num in measured:
                is_covered = line_num not in violation_lines
                line = etree.SubElement(src_node, "line")

                hits = 1 if is_covered else 0
                line.set("count", str(hits))
                line.set("num", str(line_num))
                line.set("type", "stmt")
        return root


class TestJacocoXmlCoverageReporterTest:
    MANY_VIOLATIONS = {
        Violation(3, None),
        Violation(7, None),
        Violation(11, None),
        Violation(13, None),
    }
    FEW_MEASURED = {2, 3, 5, 7, 11, 13}

    FEW_VIOLATIONS = {Violation(3, None), Violation(11, None)}
    MANY_MEASURED = {2, 3, 5, 7, 11, 13, 17}

    ONE_VIOLATION = {Violation(11, None)}
    VERY_MANY_MEASURED = {2, 3, 5, 7, 11, 13, 17, 23, 24, 25, 26, 27}

    @pytest.fixture(autouse=True)
    def patch_git_patch(self, mocker):
        # Paths generated by git_path are always the given argument
        _git_path_mock = mocker.patch(
            "diff_cover.violationsreporters.violations_reporter.GitPathTool"
        )
        _git_path_mock.relative_path = lambda path: path
        _git_path_mock.absolute_path = lambda path: path

    def test_violations(self):
        # Construct the XML report
        file_paths = ["file1.java", "subdir/file2.java"]
        violations = self.MANY_VIOLATIONS
        measured = self.FEW_MEASURED
        xml = self._coverage_xml(file_paths, violations, measured)

        # Parse the report
        coverage = XmlCoverageReporter([xml])

        # Expect that the name is set
        assert coverage.name() == "XML"

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations == coverage.violations("file1.java")
        assert measured == coverage.measured_lines("file1.java")

        # Try getting a smaller range
        result = coverage.violations("subdir/file2.java")
        assert result == violations

        # Once more on the first file (for caching)
        result = coverage.violations("file1.java")
        assert result == violations

    def test_two_inputs_first_violate(self):
        # Construct the XML report
        file_paths = ["file1.java"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = self.FEW_VIOLATIONS

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED

        xml = self._coverage_xml(file_paths, violations1, measured1)
        xml2 = self._coverage_xml(file_paths, violations2, measured2)

        # Parse the report
        coverage = XmlCoverageReporter([xml, xml2])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 == coverage.violations("file1.java")

        assert measured1 | measured2 == coverage.measured_lines("file1.java")

    def test_two_inputs_second_violate(self):
        # Construct the XML report
        file_paths = ["file1.java"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = self.FEW_VIOLATIONS

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED

        xml = self._coverage_xml(file_paths, violations1, measured1)
        xml2 = self._coverage_xml(file_paths, violations2, measured2)

        # Parse the report
        coverage = XmlCoverageReporter([xml2, xml])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 == coverage.violations("file1.java")

        assert measured1 | measured2 == coverage.measured_lines("file1.java")

    def test_three_inputs(self):
        # Construct the XML report
        file_paths = ["file1.java"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = self.FEW_VIOLATIONS
        violations3 = self.ONE_VIOLATION

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED
        measured3 = self.VERY_MANY_MEASURED

        xml = self._coverage_xml(file_paths, violations1, measured1)
        xml2 = self._coverage_xml(file_paths, violations2, measured2)
        xml3 = self._coverage_xml(file_paths, violations3, measured3)

        # Parse the report
        coverage = XmlCoverageReporter([xml2, xml, xml3])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 & violations3 == coverage.violations(
            "file1.java"
        )

        assert measured1 | measured2 | measured3 == coverage.measured_lines(
            "file1.java"
        )

    def test_different_files_in_inputs(self):
        # Construct the XML report
        xml_roots = [
            self._coverage_xml(["file.java"], self.MANY_VIOLATIONS, self.FEW_MEASURED),
            self._coverage_xml(
                ["other_file.java"], self.FEW_VIOLATIONS, self.MANY_MEASURED
            ),
        ]

        # Parse the report
        coverage = XmlCoverageReporter(xml_roots)

        assert coverage.violations("file.java") == self.MANY_VIOLATIONS
        assert coverage.violations("other_file.java") == self.FEW_VIOLATIONS

    def test_empty_violations(self):
        """
        Test that an empty violations report is handled properly
        """
        # Construct the XML report
        file_paths = ["file1.java"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = set()

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED

        xml = self._coverage_xml(file_paths, violations1, measured1)
        xml2 = self._coverage_xml(file_paths, violations2, measured2)

        # Parse the report
        coverage = XmlCoverageReporter([xml2, xml])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 == coverage.violations("file1.java")

        assert measured1 | measured2 == coverage.measured_lines("file1.java")

    def test_no_such_file(self):
        # Construct the XML report with no source files
        xml = self._coverage_xml([], [], [])

        # Parse the report
        coverage = XmlCoverageReporter(xml)

        # Expect that we get no results
        result = coverage.violations("file.java")
        assert result == set()

    def _coverage_xml(self, file_paths, violations, measured):
        """
        Build an XML tree with source files specified by `file_paths`.
        Each source fill will have the same set of covered and
        uncovered lines.

        `file_paths` is a list of path strings
        `line_dict` is a dictionary with keys that are line numbers
        and values that are True/False indicating whether the line
        is covered

        This leaves out some attributes of the Cobertura format,
        but includes all the elements.
        """
        root = etree.Element("report")
        root.set("name", "diff-cover")
        sessioninfo = etree.SubElement(root, "sessioninfo")
        sessioninfo.set("id", "C13WQ1WFHTEE-83e2bc9b")

        violation_lines = {violation.line for violation in violations}

        for path in file_paths:
            package = etree.SubElement(root, "package")
            package.set("name", os.path.dirname(path))
            src_node = etree.SubElement(package, "sourcefile")
            src_node.set("name", os.path.basename(path))

            # Create a node for each line in measured
            for line_num in measured:
                is_covered = line_num not in violation_lines
                line = etree.SubElement(src_node, "line")

                hits = 1 if is_covered else 0
                line.set("ci", str(hits))
                line.set("nr", str(line_num))
        return root


class TestLcovCoverageReporterTest:
    MANY_VIOLATIONS = {
        Violation(3, None),
        Violation(7, None),
        Violation(11, None),
        Violation(13, None),
    }
    FEW_MEASURED = {2, 3, 5, 7, 11, 13}

    FEW_VIOLATIONS = {Violation(3, None), Violation(11, None)}
    MANY_MEASURED = {2, 3, 5, 7, 11, 13, 17}

    ONE_VIOLATION = {Violation(11, None)}
    VERY_MANY_MEASURED = {2, 3, 5, 7, 11, 13, 17, 23, 24, 25, 26, 27}

    @pytest.fixture(autouse=True)
    def patch_git_patch(self, mocker):
        # Paths generated by git_path are always the given argument
        _git_path_mock = mocker.patch(
            "diff_cover.violationsreporters.violations_reporter.GitPathTool"
        )
        _git_path_mock.relative_path = lambda path: path
        _git_path_mock.absolute_path = lambda path: path

    @pytest.fixture(autouse=True)
    def setup(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)

    def test_violations(self):
        # Construct the LCOV report
        file_paths = ["file1.java", "subdir/file2.java"]
        violations = self.MANY_VIOLATIONS
        measured = self.FEW_MEASURED
        lcov = self._coverage_lcov(file_paths, violations, measured)

        # Parse the report
        coverage = LcovCoverageReporter([lcov])

        # Expect that the name is set
        assert coverage.name() == "LCOV"

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations == coverage.violations("file1.java")
        assert measured == coverage.measured_lines("file1.java")

        # Try getting a smaller range
        result = coverage.violations("subdir/file2.java")
        assert result == violations

        # Once more on the first file (for caching)
        result = coverage.violations("file1.java")
        assert result == violations

    def test_two_inputs_first_violate(self):
        # Construct the LCOV report
        file_paths = ["file1.java"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = self.FEW_VIOLATIONS

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED

        lcov = self._coverage_lcov(file_paths, violations1, measured1)
        lcov2 = self._coverage_lcov(file_paths, violations2, measured2)

        # Parse the report
        coverage = LcovCoverageReporter([lcov, lcov2])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 == coverage.violations("file1.java")

        assert measured1 | measured2 == coverage.measured_lines("file1.java")

    def test_two_inputs_second_violate(self):
        # Construct the LCOV report
        file_paths = ["file1.java"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = self.FEW_VIOLATIONS

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED

        lcov = self._coverage_lcov(file_paths, violations1, measured1)
        lcov2 = self._coverage_lcov(file_paths, violations2, measured2)

        # Parse the report
        coverage = LcovCoverageReporter([lcov2, lcov])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 == coverage.violations("file1.java")

        assert measured1 | measured2 == coverage.measured_lines("file1.java")

    def test_three_inputs(self):
        # Construct the LCOV report
        file_paths = ["file1.java"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = self.FEW_VIOLATIONS
        violations3 = self.ONE_VIOLATION

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED
        measured3 = self.VERY_MANY_MEASURED

        lcov = self._coverage_lcov(file_paths, violations1, measured1)
        lcov2 = self._coverage_lcov(file_paths, violations2, measured2)
        lcov3 = self._coverage_lcov(file_paths, violations3, measured3)

        # Parse the report
        coverage = LcovCoverageReporter([lcov2, lcov, lcov3])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 & violations3 == coverage.violations(
            "file1.java"
        )

        assert measured1 | measured2 | measured3 == coverage.measured_lines(
            "file1.java"
        )

    def test_different_files_in_inputs(self):
        # Construct the LCOV report
        lcov_repots = [
            self._coverage_lcov(["file.java"], self.MANY_VIOLATIONS, self.FEW_MEASURED),
            self._coverage_lcov(
                ["other_file.java"], self.FEW_VIOLATIONS, self.MANY_MEASURED
            ),
        ]

        # Parse the report
        coverage = LcovCoverageReporter(lcov_repots)

        assert coverage.violations("file.java") == self.MANY_VIOLATIONS
        assert coverage.violations("other_file.java") == self.FEW_VIOLATIONS

    def test_empty_violations(self):
        """
        Test that an empty violations report is handled properly
        """
        # Construct the LCOV report
        file_paths = ["file1.java"]

        violations1 = self.MANY_VIOLATIONS
        violations2 = set()

        measured1 = self.FEW_MEASURED
        measured2 = self.MANY_MEASURED

        lcov = self._coverage_lcov(file_paths, violations1, measured1)
        lcov2 = self._coverage_lcov(file_paths, violations2, measured2)

        # Parse the report
        coverage = LcovCoverageReporter([lcov2, lcov])

        # By construction, each file has the same set
        # of covered/uncovered lines
        assert violations1 & violations2 == coverage.violations("file1.java")

        assert measured1 | measured2 == coverage.measured_lines("file1.java")

    def test_no_such_file(self):
        # Construct the LCOV report with no source files
        lcov = self._coverage_lcov([], [], [])

        # Parse the report
        coverage = LcovCoverageReporter(lcov)

        # Expect that we get no results
        result = coverage.violations("file.java")
        assert result == set()

    def test_parse_branch_and_function_coverage(self):
        file_paths = ["file1.cpp"]
        # Add violations for lines 15 and 60 to test DA=0 + branch override scenarios
        violations = {Violation(15, None), Violation(60, None)}
        measured = {10, 15, 25, 35, 45, 55, 60}

        # Branch data:
        # line 15: DA=0, but branches executed (should override to use branch executions)
        # line 20: two branches, both hit (should be covered)
        # line 30: two branches, one not hit (should NOT be covered)
        # line 60: DA=0, branches also 0 executions (should remain 0, no override)
        branch_data = {
            "file1.cpp": {
                15: [
                    (0, 0, 2),
                    (0, 1, 3),
                ],  # DA=0 but branches executed (override case)
                20: [(0, 0, 1), (0, 1, 2)],  # both branches hit
                30: [(0, 0, 1), (0, 1, 0)],  # one branch not hit
                60: [(0, 0, 0), (0, 1, 0)],  # DA=0 and branches=0 (no override)
            }
        }
        # Function data:
        # line 40: function hit (should be covered)
        # line 50: function not hit (should NOT be covered)
        function_data = {
            "file1.cpp": {
                "func_hit": (40, 40, 3),
                "func_not_hit": (50, 50, 0),
            }
        }

        lcov_report = self._coverage_lcov(
            file_paths,
            violations,
            measured,
            branch_data=branch_data,
            function_data=function_data,
        )

        lcov_report_obsolete_format = self._coverage_lcov(
            file_paths,
            violations,
            measured,
            branch_data=branch_data,
            function_data=function_data,
            use_obsolete_format=True,
        )

        assert lcov_report == {
            "file1.cpp": {
                10: 1,  # covered (no branch/function)
                15: 5,  # DA=0 but branch executions=2+3=5 (override case, lines 417-418)
                20: 3,  # all branches hit, should be covered
                25: 1,  # covered (no branch/function)
                30: 0,  # not all branches hit, should NOT be covered
                35: 1,  # covered (no branch/function)
                40: 3,  # function hit, should be covered
                45: 1,  # covered (no branch/function)
                50: 0,  # function not hit, should NOT be covered
                55: 1,  # covered (no branch/function)
                60: 0,  # DA=0 and branch executions=0 (no override)
            }
        }
        assert lcov_report_obsolete_format == lcov_report

    def _coverage_lcov(
        self,
        file_paths,
        violations,
        measured,
        branch_data=None,
        function_data=None,
        use_obsolete_format=False,
    ):
        """
        Build an LCOV document based on the provided arguments.
        Optionally include branch and function coverage data.
        - branch_data: dict {file: {line: [(block, branch, taken), ...]}}
        - function_data: dict {file: {func_name: (line, end_line, hit_count)}}
        """
        violation_lines = {violation.line for violation in violations}
        branch_data = branch_data or {}
        function_data = function_data or {}

        with open("temp.lcov", "w", encoding="utf-8") as f:
            for path in file_paths:
                f.write(f"SF:{path}\n")
                # Write function data
                for func_name, (line, end_line, _) in function_data.get(
                    path, {}
                ).items():
                    if use_obsolete_format:
                        f.write(f"FN:{line},{end_line},{func_name}\n")
                    else:
                        f.write(f"FN:{line},{func_name}\n")
                for func_name, (_, _, hit_count) in function_data.get(path, {}).items():
                    f.write(f"FNDA:{hit_count},{func_name}\n")
                # Write line data
                for line in measured:
                    if line in violation_lines:
                        f.write(f"DA:{line},0\n")
                    else:
                        f.write(f"DA:{line},1\n")
                # Write branch data
                for line, branches in branch_data.get(path, {}).items():
                    for block, branch, taken in branches:
                        f.write(f"BRDA:{line},{block},{branch},{taken}\n")
                f.write("end_of_record\n")

        # Parse and return the LCOV report
        lcov_report = LcovCoverageReporter.parse(f.name)
        return lcov_report


class TestPycodestyleQualityReporterTest:
    def test_quality(self, mocker, process_patcher):
        # Patch the output of `pycodestyle`
        mocker.patch.object(Popen, "communicate")
        return_string = (
            "\n"
            + dedent(
                """
                ../new_file.py:1:17: E231 whitespace
                ../new_file.py:3:13: E225 whitespace
                ../new_file.py:7:1: E302 blank lines
            """
            ).strip()
            + "\n"
        )
        process_patcher((return_string.encode("utf-8"), b""))

        # Parse the report
        quality = QualityReporter(pycodestyle_driver)

        # Expect that the name is set
        assert quality.name() == "pycodestyle"

        # Measured_lines is undefined for
        # a quality reporter since all lines are measured
        assert quality.measured_lines("../new_file.py") is None

        # Expect that we get the right violations
        expected_violations = [
            Violation(1, "E231 whitespace"),
            Violation(3, "E225 whitespace"),
            Violation(7, "E302 blank lines"),
        ]

        assert expected_violations == quality.violations("../new_file.py")

    def test_no_quality_issues_newline(self, process_patcher):
        # Patch the output of `pycodestyle`
        process_patcher((b"\n", b""))

        # Parse the report
        quality = QualityReporter(pycodestyle_driver)
        assert quality.violations("file1.py") == []

    def test_no_quality_issues_emptystring(self, process_patcher):
        # Patch the output of `pycodestyle`
        process_patcher((b"", b""))

        # Parse the report
        quality = QualityReporter(pycodestyle_driver)
        assert quality.violations("file1.py") == []

    def test_quality_error(self, mocker, process_patcher):
        # Patch the output of `pycodestyle`
        process_patcher((b"", "whoops Ƕئ".encode()), status_code=255)
        code = mocker.patch("diff_cover.violationsreporters.base.run_command_for_code")
        code.return_value = 0
        # Parse the report
        quality = QualityReporter(pycodestyle_driver)

        # Expect that the name is set
        assert quality.name() == "pycodestyle"
        with pytest.raises(CommandError, match="whoops Ƕئ"):
            quality.violations("file1.py")

    def test_no_such_file(self):
        quality = QualityReporter(pycodestyle_driver)

        # Expect that we get no results
        result = quality.violations("")
        assert result == []

    def test_no_python_file(self):
        quality = QualityReporter(pycodestyle_driver)
        file_paths = ["file1.coffee", "subdir/file2.js"]
        # Expect that we get no results because no Python files
        for path in file_paths:
            result = quality.violations(path)
            assert result == []

    def test_quality_pregenerated_report(self):
        # When the user provides us with a pre-generated pycodestyle report
        # then use that instead of calling pycodestyle directly.
        pycodestyle_reports = [
            BytesIO(
                (
                    "\n"
                    + dedent(
                        """
                path/to/file.py:1:17: E231 whitespace
                path/to/file.py:3:13: E225 whitespace
                another/file.py:7:1: E302 blank lines
            """
                    ).strip()
                    + "\n"
                ).encode("utf-8")
            ),
            BytesIO(
                (
                    "\n"
                    + dedent(
                        """
                path/to/file.py:24:2: W123 \u9134\u1912
                another/file.py:50:1: E302 blank lines
            """
                    ).strip()
                    + "\n"
                ).encode("utf-8")
            ),
        ]

        # Parse the report
        quality = QualityReporter(pycodestyle_driver, reports=pycodestyle_reports)

        # Measured_lines is undefined for
        # a quality reporter since all lines are measured
        assert quality.measured_lines("path/to/file.py") is None

        # Expect that we get the right violations
        expected_violations = [
            Violation(1, "E231 whitespace"),
            Violation(3, "E225 whitespace"),
            Violation(24, "W123 \u9134\u1912"),
        ]

        # We're not guaranteed that the violations are returned
        # in any particular order.
        actual_violations = quality.violations("path/to/file.py")

        assert len(actual_violations) == len(expected_violations)
        for expected in expected_violations:
            assert expected in actual_violations


class TestPyflakesQualityReporterTest:
    """
    Tests for Pyflakes quality violations
    """

    def test_quality(self, process_patcher):
        # Patch the output of `pyflakes`
        return_string = (
            "\n"
            + dedent(
                """
                ../new_file.py:328: undefined name '_thing'
                ../new_file.py:418: 'random' imported but unused
            """
            ).strip()
            + "\n"
        )
        process_patcher((return_string.encode("utf-8"), b""))

        # Parse the report
        quality = QualityReporter(pyflakes_driver)

        # Expect that the name is set
        assert quality.name() == "pyflakes"

        # Measured_lines is undefined for
        # a quality reporter since all lines are measured
        assert quality.measured_lines("../new_file.py") is None

        # Expect that we get the right violations
        expected_violations = [
            Violation(328, "undefined name '_thing'"),
            Violation(418, "'random' imported but unused"),
        ]

        assert expected_violations == quality.violations("../new_file.py")

    def test_no_quality_issues_newline(self, process_patcher):
        process_patcher((b"\n", b""))
        # Parse the report
        quality = QualityReporter(pyflakes_driver)
        assert quality.violations("file1.py") == []

    def test_no_quality_issues_emptystring(self, process_patcher):
        # Patch the output of `pyflakes`
        process_patcher((b"", b""))
        # Parse the report
        quality = QualityReporter(pyflakes_driver)
        assert quality.violations("file1.py") == []

    def test_quality_error(self, mocker, process_patcher):
        # Patch the output of `pyflakes`
        process_patcher((b"", b"whoops"), status_code=255)

        code = mocker.patch("diff_cover.violationsreporters.base.run_command_for_code")
        code.return_value = 0
        quality = QualityReporter(pyflakes_driver)

        # Expect that the name is set
        assert quality.name() == "pyflakes"

        with pytest.raises(CommandError):
            quality.violations("file1.py")

    def test_no_such_file(self):
        quality = QualityReporter(pyflakes_driver)

        # Expect that we get no results
        result = quality.violations("")
        assert result == []

    def test_no_python_file(self):
        quality = QualityReporter(pyflakes_driver)
        file_paths = ["file1.coffee", "subdir/file2.js"]
        # Expect that we get no results because no Python files
        for path in file_paths:
            result = quality.violations(path)
            assert result == []

    def test_quality_pregenerated_report(self):
        # When the user provides us with a pre-generated pyflakes report
        # then use that instead of calling pyflakes directly.
        pyflakes_reports = [
            BytesIO(
                (
                    "\n"
                    + dedent(
                        """
                path/to/file.py:1: undefined name 'this'
                path/to/file.py:3: 'random' imported but unused
                another/file.py:7: 'os' imported but unused
            """
                    ).strip()
                    + "\n"
                ).encode("utf-8")
            ),
            BytesIO(
                (
                    "\n"
                    + dedent(
                        """
                path/to/file.py:24: undefined name 'that'
                another/file.py:50: undefined name 'another'
            """
                    ).strip()
                    + "\n"
                ).encode("utf-8")
            ),
        ]

        # Parse the report
        quality = QualityReporter(pyflakes_driver, reports=pyflakes_reports)

        # Measured_lines is undefined for
        # a quality reporter since all lines are measured
        assert quality.measured_lines("path/to/file.py") is None

        # Expect that we get the right violations
        expected_violations = [
            Violation(1, "undefined name 'this'"),
            Violation(3, "'random' imported but unused"),
            Violation(24, "undefined name 'that'"),
        ]

        # We're not guaranteed that the violations are returned
        # in any particular order.
        actual_violations = quality.violations("path/to/file.py")

        assert len(actual_violations) == len(expected_violations)
        for expected in expected_violations:
            assert expected in actual_violations


class TestFlake8QualityReporterTest:
    def test_quality(self, process_patcher):
        # Patch the output of `flake8`
        return_string = (
            "\n"
            + dedent(
                """
                ../new_file.py:1:17: E231 whitespace
                ../new_file.py:3:13: E225 whitespace
                ../new_file.py:7:1: E302 blank lines
                ../new_file.py:8:1: W191 indentation contains tabs
                ../new_file.py:10:1: F841 local variable name is assigned to but never used
                ../new_file.py:20:1: C901 'MyModel.mymethod' is too complex (14)
                ../new_file.py:50:1: N801 class names should use CapWords convention
                ../new_file.py:60:10: T000 Todo note found.
                ../new_file.py:70:0: I100 statements are in the wrong order.
                ../new_file.py:80:0: B901 blind except: statement
                ../new_file.py:90:0: D207 Docstring is under-indented
                ../new_file.py:100:0: S100 Snippet found
                ../new_file.py:110:0: Q000 Remove Single quotes
                ../new_file.py:120:0: ABCXYZ000 Dummy
            """
            ).strip()
            + "\n"
        )
        process_patcher((return_string.encode("utf-8"), b""))

        # Parse the report
        quality = QualityReporter(flake8_driver)

        # Expect that the name is set
        assert quality.name() == "flake8"

        # Measured_lines is undefined for
        # a quality reporter since all lines are measured
        assert quality.measured_lines("../new_file.py") is None

        # Expect that we get the right violations
        expected_violations = [
            Violation(1, "E231 whitespace"),
            Violation(3, "E225 whitespace"),
            Violation(7, "E302 blank lines"),
            Violation(8, "W191 indentation contains tabs"),
            Violation(10, "F841 local variable name is assigned to but never used"),
            Violation(20, "C901 'MyModel.mymethod' is too complex (14)"),
            Violation(50, "N801 class names should use CapWords convention"),
            Violation(60, "T000 Todo note found."),
            Violation(70, "I100 statements are in the wrong order."),
            Violation(80, "B901 blind except: statement"),
            Violation(90, "D207 Docstring is under-indented"),
            Violation(100, "S100 Snippet found"),
            Violation(110, "Q000 Remove Single quotes"),
            Violation(120, "ABCXYZ000 Dummy"),
        ]

        assert expected_violations == quality.violations("../new_file.py")

    def test_no_quality_issues_newline(self, process_patcher):
        process_patcher((b"\n", b""), 0)

        quality = QualityReporter(flake8_driver)
        assert quality.violations("file1.py") == []

    def test_no_quality_issues_emptystring(self, process_patcher):
        # Patch the output of `flake8`
        process_patcher((b"", b""), 0)

        # Parse the report
        quality = QualityReporter(flake8_driver)
        assert quality.violations("file1.py") == []

    def test_quality_error(self, mocker, process_patcher):
        # Patch the output of `flake8`
        process_patcher((b"", "whoops Ƕئ".encode()), status_code=255)

        # Parse the report
        code = mocker.patch("diff_cover.violationsreporters.base.run_command_for_code")
        code.return_value = 0
        quality = QualityReporter(flake8_driver)

        # Expect that the name is set
        assert quality.name() == "flake8"
        with pytest.raises(CommandError, match="whoops Ƕئ"):
            quality.violations("file1.py")

    def test_no_such_file(self):
        quality = QualityReporter(flake8_driver)

        # Expect that we get no results
        result = quality.violations("")
        assert result == []

    def test_no_python_file(self):
        quality = QualityReporter(flake8_driver)
        file_paths = ["file1.coffee", "subdir/file2.js"]
        # Expect that we get no results because no Python files
        for path in file_paths:
            result = quality.violations(path)
            assert result == []

    @pytest.mark.disable_all_files_exist
    def test_file_does_not_exist(self):
        quality = QualityReporter(flake8_driver)
        file_paths = ["ajshdjlasdhajksdh.py"]
        # Expect that we get no results because that file does not exist
        for path in file_paths:
            result = quality.violations(path)
            assert result == []

    def test_quality_pregenerated_report(self):
        # When the user provides us with a pre-generated flake8 report
        # then use that instead of calling flake8 directly.
        flake8_reports = [
            BytesIO(
                (
                    "\n"
                    + dedent(
                        """
                path/to/file.py:1:17: E231 whitespace
                path/to/file.py:3:13: E225 whitespace
                another/file.py:7:1: E302 blank lines
            """
                    ).strip()
                    + "\n"
                ).encode("utf-8")
            ),
            BytesIO(
                (
                    "\n"
                    + dedent(
                        """
                path/to/file.py:24:2: W123 \u9134\u1912
                another/file.py:50:1: E302 blank lines
            """
                    ).strip()
                    + "\n"
                ).encode("utf-8")
            ),
        ]

        # Parse the report
        quality = QualityReporter(flake8_driver, reports=flake8_reports)

        # Measured_lines is undefined for
        # a quality reporter since all lines are measured
        assert quality.measured_lines("path/to/file.py") is None

        # Expect that we get the right violations
        expected_violations = [
            Violation(1, "E231 whitespace"),
            Violation(3, "E225 whitespace"),
            Violation(24, "W123 \u9134\u1912"),
        ]

        # We're not guaranteed that the violations are returned
        # in any particular order.
        actual_violations = quality.violations("path/to/file.py")

        assert len(actual_violations) == len(expected_violations)
        for expected in expected_violations:
            assert expected in actual_violations


class TestPydocstlyeQualityReporterTest:
    """Tests for pydocstyle quality violations."""

    def test_no_such_file(self):
        """Expect that we get no results."""
        quality = QualityReporter(pydocstyle_driver)

        result = quality.violations("")
        assert result == []

    def test_no_python_file(self):
        """Expect that we get no results because no Python files."""
        quality = QualityReporter(pydocstyle_driver)
        file_paths = ["file1.coffee", "subdir/file2.js"]
        for path in file_paths:
            result = quality.violations(path)
            assert result == []

    def test_quality(self, process_patcher):
        """Integration test."""
        # Patch the output of `pydocstye`
        process_patcher(
            (
                dedent(
                    """
            ../new_file.py:1 at module level:
                    D100: Missing docstring in public module
            ../new_file.py:13 in public function `gather`:
                    D103: Missing docstring in public function
            """
                )
                .strip()
                .encode("ascii"),
                "",
            )
        )

        expected_violations = [
            Violation(1, "D100: Missing docstring in public module"),
            Violation(13, "D103: Missing docstring in public function"),
        ]

        # Parse the report
        quality = QualityReporter(pydocstyle_driver)

        # Expect that the name is set
        assert quality.name() == "pydocstyle"

        # Measured_lines is undefined for a
        # quality reporter since all lines are measured
        assert quality.measured_lines("../new_file.py") is None

        # Expect that we get violations for file1.py only
        # We're not guaranteed that the violations are returned
        # in any particular order.
        actual_violations = quality.violations("../new_file.py")
        assert len(actual_violations) == len(expected_violations)
        for expected in expected_violations:
            assert expected in actual_violations


class TestPylintQualityReporterTest:
    @pytest.mark.disable_all_files_exist
    def test_no_such_file(self):
        file_paths = ["ajshdjlasdhajksdh.py"]
        quality = QualityReporter(PylintDriver())

        # Expect that we get no results
        for path in file_paths:
            result = quality.violations(path)
            assert result == []

    def test_no_python_file(self):
        quality = QualityReporter(PylintDriver())
        file_paths = ["file1.coffee", "subdir/file2.js"]
        # Expect that we get no results because no Python files
        for path in file_paths:
            result = quality.violations(path)
            assert result == []

    def test_quality(self, process_patcher):
        # Patch the output of `pylint`
        process_patcher(
            (
                dedent(
                    """
            file1.py:1: [C0111] Missing docstring
            file1.py:1: [C0111, func_1] Missing docstring
            file1.py:2: [W0612, cls_name.func] Unused variable 'd'
            file1.py:2: [W0511] TODO: Not the real way we'll store usages!
            file1.py:579: [F0401] Unable to import 'rooted_paths'
            file1.py:113: [W0613, cache_relation.clear_pk] Unused argument 'cls'
            file1.py:150: [F0010] error while code parsing ([Errno 2] No such file or directory)
            file1.py:149: [C0324, Foo.__dict__] Comma not followed by a space
                self.peer_grading._find_corresponding_module_for_location(Location('i4x','a','b','c','d'))
            file1.py:162: [R0801] Similar lines in 2 files
            ==file1:162
            ==student.views:4
            import json
            import logging
            import random
            file2.py:170: [R0801] Similar lines in 2 files
            ==file1:[170:172]
            ==student.views:[4:6]
            import foo
            import bar
            path/to/file2.py:100: [W0212, openid_login_complete] Access to a protected member
            """
                )
                .strip()
                .encode("ascii"),
                "",
            )
        )

        expected_violations = [
            Violation(1, "C0111: Missing docstring"),
            Violation(1, "C0111: func_1: Missing docstring"),
            Violation(2, "W0612: cls_name.func: Unused variable 'd'"),
            Violation(2, "W0511: TODO: Not the real way we'll store usages!"),
            Violation(579, "F0401: Unable to import 'rooted_paths'"),
            Violation(
                150,
                "F0010: error while code parsing ([Errno 2] No such file or directory)",
            ),
            Violation(149, "C0324: Foo.__dict__: Comma not followed by a space"),
            Violation(162, "R0801: Similar lines in 2 files"),
            Violation(170, "R0801: Similar lines in 2 files"),
            Violation(113, "W0613: cache_relation.clear_pk: Unused argument 'cls'"),
        ]

        # Parse the report
        quality = QualityReporter(PylintDriver())

        # Expect that the name is set
        assert quality.name() == "pylint"

        # Measured_lines is undefined for a
        # quality reporter since all lines are measured
        assert quality.measured_lines("file1.py") is None

        # Expect that we get violations for file1.py only
        # We're not guaranteed that the violations are returned
        # in any particular order.
        actual_violations = quality.violations("file1.py")
        assert len(actual_violations) == len(expected_violations)
        for expected in expected_violations:
            assert expected in actual_violations

    def test_unicode(self, process_patcher):
        process_patcher(
            (
                dedent(
                    """
            file_\u6729.py:616: [W1401] Anomalous backslash in string: '\u5922'. String constant might be missing an r prefix.
            file.py:2: [W0612, cls_name.func_\u9492] Unused variable '\u2920'
            """
                ).encode("utf-8"),
                "",
            ),
            0,
        )
        quality = QualityReporter(PylintDriver())
        violations = quality.violations("file_\u6729.py")
        assert violations == [
            Violation(
                616,
                "W1401: Anomalous backslash in string: '\u5922'. "
                "String constant might be missing an r prefix.",
            ),
        ]

        violations = quality.violations("file.py")
        assert violations == [
            Violation(2, "W0612: cls_name.func_\u9492: Unused variable '\u2920'")
        ]

    def test_unicode_continuation_char(self, process_patcher):
        process_patcher((b"file.py:2: [W1401] Invalid char '\xc3'", ""), 0)
        # Since we are replacing characters we can't interpet, this should
        # return a valid string with the char replaced with '?'
        quality = QualityReporter(PylintDriver())
        violations = quality.violations("file.py")
        assert violations == [Violation(2, "W1401: Invalid char '\ufffd'")]

    def test_non_integer_line_num(self, process_patcher):
        process_patcher(
            (
                dedent(
                    """
            file.py:not_a_number: C0111: Missing docstring
            file.py:\u8911: C0111: Missing docstring
        """
                ).encode("utf-8"),
                "",
            ),
            0,
        )

        # None of the violations have a valid line number, so they should all be skipped
        violations = QualityReporter(PylintDriver()).violations("file.py")
        assert violations == []

    def test_quality_deprecation_warning(self, process_patcher):
        # Patch the output stderr/stdout and returncode of `pylint`
        process_patcher(
            (
                b"file1.py:1: [C0111] Missing docstring\n"
                b"file1.py:1: [C0111, func_1] Missing docstring",
                b"Foobar: pylintrc deprecation warning",
            ),
            0,
        )

        # Parse the report
        quality = QualityReporter(PylintDriver())
        actual_violations = quality.violations("file1.py")

        # Assert that pylint successfully runs and finds 2 violations
        assert len(actual_violations) == 2

    def test_quality_error(self, mocker, process_patcher):
        # Patch the output stderr/stdout and returncode of `pylint`
        process_patcher(
            (b"file1.py:1: [C0111] Missing docstring", b"oops"), status_code=1
        )

        # Parse the report
        code = mocker.patch(
            "diff_cover.violationsreporters.violations_reporter.run_command_for_code"
        )
        code.return_value = 0
        quality = QualityReporter(PylintDriver())

        # Expect an error
        with pytest.raises(CommandError):
            quality.violations("file1.py")

    def test_no_quality_issues_newline(self, process_patcher):
        process_patcher((b"\n", b""), 0)

        # Parse the report
        quality = QualityReporter(PylintDriver())
        assert quality.violations("file1.py") == []

    def test_no_quality_issues_emptystring(self, process_patcher):
        # Patch the output of `pylint`
        process_patcher((b"", b""), 0)

        # Parse the report
        quality = QualityReporter(PylintDriver())
        assert quality.violations("file1.py") == []

    def test_quality_pregenerated_report(self):
        # When the user provides us with a pre-generated pylint report
        # then use that instead of calling pylint directly.
        pylint_reports = [
            BytesIO(
                dedent(
                    """
                path/to/file.py:1: [C0111] Missing docstring
                path/to/file.py:57: [W0511] TODO the name of this method is a little bit confusing
                another/file.py:41: [W1201, assign_default_role] Specify string format arguments as logging function parameters
                another/file.py:175: [C0322, Foo.bar] Operator not preceded by a space
                        x=2+3
                          ^
                        Unicode: \u9404 \u1239
                another/file.py:259: [C0103, bar] Invalid name "\u4920" for type variable (should match [a-z_][a-z0-9_]{2,30}$)
            """
                )
                .strip()
                .encode("utf-8")
            ),
            BytesIO(
                dedent(
                    """
            path/to/file.py:183: [C0103, Foo.bar.gettag] Invalid name "\u3240" for type argument (should match [a-z_][a-z0-9_]{2,30}$)
            another/file.py:183: [C0111, Foo.bar.gettag] Missing docstring
            """
                )
                .strip()
                .encode("utf-8")
            ),
        ]

        # Generate the violation report
        quality = QualityReporter(PylintDriver(), reports=pylint_reports)

        # Expect that we get the right violations
        expected_violations = [
            Violation(1, "C0111: Missing docstring"),
            Violation(
                57, "W0511: TODO the name of this method is a little bit confusing"
            ),
            Violation(
                183,
                'C0103: Foo.bar.gettag: Invalid name "\u3240" for type argument (should match [a-z_][a-z0-9_]{2,30}$)',
            ),
        ]

        # We're not guaranteed that the violations are returned
        # in any particular order.
        actual_violations = quality.violations("path/to/file.py")
        assert len(actual_violations) == len(expected_violations)
        for expected in expected_violations:
            assert expected in actual_violations

    def test_quality_pregenerated_report_continuation_char(self):
        # The report contains a non-ASCII continuation char
        pylint_reports = [BytesIO(b"file.py:2: [W1401] Invalid char '\xc3'")]

        # Generate the violation report
        quality = QualityReporter(PylintDriver(), reports=pylint_reports)
        violations = quality.violations("file.py")

        # Expect that the char is replaced
        assert violations == [Violation(2, "W1401: Invalid char '\ufffd'")]

    def test_windows_paths(self, process_patcher):
        process_patcher(
            ("this\\is\\win.py:42: [C0111] Missing docstring", ""),
            0,
        )
        quality = QualityReporter(PylintDriver())
        violations = quality.violations("this/is/win.py")
        assert violations == [Violation(42, "C0111: Missing docstring")]


class JsQualityBaseReporterMixin:
    """
    Generic JS linter tests. Assumes the linter is not available as a python
    library, but is available on the commandline.
    """

    @pytest.fixture(autouse=True)
    def patcher(self, mocker):
        # Mock patch the installation of the linter
        self._mock_command_simple = mocker.patch(
            "diff_cover.violationsreporters.violations_reporter.run_command_for_code"
        )
        self._mock_command_simple.return_value = 0
        # Mock patch the linter results
        self._mock_communicate = mocker.patch.object(subprocess, "Popen")
        self.subproc_mock = mocker.MagicMock()
        self.subproc_mock.returncode = 0
        self.subproc_mock.__enter__ = mocker.Mock(return_value=self.subproc_mock)
        self.subproc_mock.__exit__ = mocker.Mock(return_value=None)

    def _get_out(self):
        """
        get Object Under Test
        """
        return None  # pragma: no cover

    def test_quality(self):
        """
        Test basic scenarios, including special characters that would appear in JavaScript and mixed quotation marks
        """
        # Patch the output of the linter cmd
        return_string = (
            "\n"
            + dedent(
                """
                ../test_file.js: line 3, col 9, Missing "use strict" statement.
                ../test_file.js: line 10, col 17, '$hi' is defined but never used.
            """
            ).strip()
            + "\n"
        )
        self.subproc_mock.communicate.return_value = (
            return_string.encode("utf-8"),
            b"",
        )
        self._mock_communicate.return_value = self.subproc_mock

        # Parse the report
        quality = QualityReporter(self._get_out())

        # Expect that the name is set
        assert quality.name() == self.quality_name

        # Measured_lines is undefined for
        # a quality reporter since all lines are measured
        assert quality.measured_lines("../blah.js") is None

        # Expect that we get the right violations
        expected_violations = [
            Violation(3, 'Missing "use strict" statement.'),
            Violation(10, "'$hi' is defined but never used."),
        ]

        assert expected_violations == quality.violations("../test_file.js")

    def test_no_quality_issues_newline(self):
        # Patch the output of the linter cmd
        self.subproc_mock.communicate.return_value = (b"\n", b"")
        self._mock_communicate.return_value = self.subproc_mock

        # Parse the report
        quality = QualityReporter(self._get_out())
        assert quality.violations("test-file.js") == []

    def test_no_quality_issues_emptystring(self):
        # Patch the output of the linter cmd
        self.subproc_mock.communicate.return_value = (b"", b"")
        self._mock_communicate.return_value = self.subproc_mock

        # Parse the report
        quality = QualityReporter(self._get_out())
        assert quality.violations("file1.js") == []

    def test_quality_error(self, mocker, process_patcher):
        process_patcher((b"", "whoops Ƕئ".encode()), status_code=1)
        code = mocker.patch("diff_cover.violationsreporters.base.run_command_for_code")
        code.return_value = 0
        # Parse the report
        quality = QualityReporter(self._get_out())

        # Expect that the name is set
        assert quality.name() == self.quality_name
        with pytest.raises(CommandError, match="whoops Ƕئ"):
            quality.violations("file1.js")

    def test_no_such_file(self):
        quality = QualityReporter(self._get_out())

        # Expect that we get no results
        result = quality.violations("")
        assert result == []

    def test_no_js_file(self):
        quality = QualityReporter(self._get_out())
        file_paths = ["file1.py", "subdir/file2.java"]
        # Expect that we get no results because no JS files
        for path in file_paths:
            result = quality.violations(path)
            assert result == []

    def test_quality_pregenerated_report(self):
        # When the user provides us with a pre-generated linter report
        # then use that instead of calling linter directly.
        reports = [
            BytesIO(
                (
                    "\n"
                    + dedent(
                        """
                path/to/file.js: line 3, col 9, Missing "use strict" statement.
                path/to/file.js: line 10, col 130, Line is too long.
                another/file.js: line 1, col 1, 'require' is not defined.
            """
                    ).strip()
                    + "\n"
                ).encode("utf-8")
            ),
            BytesIO(
                (
                    "\n"
                    + dedent(
                        """
                path/to/file.js: line 12, col 14, \u9134\u1912
                path/to/file.js: line 10, col 17, '$hi' is defined but never used.
            """
                    ).strip()
                    + "\n"
                ).encode("utf-8")
            ),
        ]

        # Parse the report
        quality = QualityReporter(self._get_out(), reports=reports)

        # Measured_lines is undefined for
        # a quality reporter since all lines are measured
        assert quality.measured_lines("path/to/file.js") is None

        # Expect that we get the right violations
        expected_violations = [
            Violation(3, 'Missing "use strict" statement.'),
            Violation(10, "Line is too long."),
            Violation(10, "'$hi' is defined but never used."),
            Violation(12, "\u9134\u1912"),
        ]

        # We're not guaranteed that the violations are returned
        # in any particular order.
        actual_violations = quality.violations("path/to/file.js")

        assert len(actual_violations) == len(expected_violations)
        for expected in expected_violations:
            assert expected in actual_violations

    def test_not_installed(self, mocker):
        """
        If linter is not available via commandline, it should raise
        an EnvironmentError
        """
        self._mock_command_simple = mocker.patch(
            "diff_cover.violationsreporters.violations_reporter.run_command_for_code"
        )
        self._mock_command_simple.return_value = 1
        with pytest.raises(EnvironmentError):
            QualityReporter(self._get_out()).violations("test.js")


class TestJsHintQualityReporterTest(JsQualityBaseReporterMixin):
    """
    JsHintQualityReporter tests. Assumes JsHint is not available as a python
    library, but is available on the commandline.
    """

    quality_name = "jshint"

    def _get_out(self):
        return jshint_driver


class TestESLintQualityReporterTest(JsQualityBaseReporterMixin):
    """
    ESLintQualityReporter tests. Assumes ESLint is not available as a python
    library, but is available on the commandline.
    """

    quality_name = "eslint"

    def _get_out(self):
        return EslintDriver()

    def test_report_root_path(self):
        reports = [
            BytesIO(b"foo/bar/path/to/file.js: line 3, col 9, Found issue"),
        ]

        driver = self._get_out()
        driver.add_driver_args(report_root_path="foo/bar")
        quality = QualityReporter(driver, reports=reports)

        expected_violations = [Violation(3, "Found issue")]
        actual_violations = quality.violations("path/to/file.js")
        assert actual_violations == expected_violations


class TestShellCheckQualityReporterTest:
    """Tests for shellcheck quality violations."""

    def test_no_such_file(self):
        """Expect that we get no results."""
        quality = QualityReporter(shellcheck_driver)

        result = quality.violations("")
        assert result == []

    def test_no_shell_file(self):
        """Expect that we get no results because no shell files."""
        quality = QualityReporter(shellcheck_driver)
        file_paths = ["file1.coffee", "subdir/file2.hs"]
        for path in file_paths:
            result = quality.violations(path)
            assert result == []

    def test_quality(self, process_patcher):
        """Integration test."""
        process_patcher(
            (
                dedent(
                    """
            foo/bar/path/to/file.sh:2:18: note: Double quote to prevent globbing and word splitting. [SC2086]
            foo/bar/path/to/file.sh:53:10: warning: Use 'cd ... || exit' or 'cd ... || return' in case cd fails. [SC2164]
            """
                )
                .strip()
                .encode("ascii"),
                "",
            )
        )

        expected_violations = [
            Violation(
                2,
                "18: note: Double quote to prevent globbing and word splitting. [SC2086]",
            ),
            Violation(
                53,
                "10: warning: Use 'cd ... || exit' or 'cd ... || return' in case cd fails. [SC2164]",
            ),
        ]

        # Parse the report
        quality = QualityReporter(shellcheck_driver)

        # Expect that the name is set
        assert quality.name() == "shellcheck"

        # Measured_lines is undefined for a
        # quality reporter since all lines are measured
        assert quality.measured_lines("foo/bar/path/to/file.sh") is None

        # Expect that we get violations for file1.py only
        # We're not guaranteed that the violations are returned
        # in any particular order.
        actual_violations = quality.violations("foo/bar/path/to/file.sh")
        assert len(actual_violations) == len(expected_violations)
        for expected in expected_violations:
            assert expected in actual_violations


class TestSimpleCommandTestCase:
    """
    Tests that the exit code detected by the method is passed as the return value of the method.
    """

    @pytest.fixture(autouse=True)
    def patcher(self, mocker):
        self._mock_communicate = mocker.patch.object(subprocess, "Popen")
        self.subproc_mock = mocker.MagicMock()
        self.subproc_mock.__enter__ = mocker.Mock(return_value=self.subproc_mock)
        self.subproc_mock.__exit__ = mocker.Mock(return_value=None)

    def test_run_simple_failure(self):
        # command_simple should fail
        self.subproc_mock.returncode = 127
        self._mock_communicate.return_value = self.subproc_mock
        # Create an implementation of BaseQualityReporter and explicitly call _run_command_simple
        bad_command = run_command_for_code("foo")
        assert bad_command == 127

    def test_run_simple_success(self):
        self.subproc_mock.returncode = 0
        self._mock_communicate.return_value = self.subproc_mock
        # Create an implementation of BaseQualityReporter and explicitly call _run_command_simple
        good_command = run_command_for_code("foo")
        assert good_command == 0


class TestSubprocessErrorTestCase:
    """Error in subprocess call(s)"""

    @pytest.fixture(autouse=True)
    def patcher(self, mocker):
        # when you create a new subprocess.Popen() object and call .communicate()
        # on it, raise an OSError
        popen_instance = mocker.Mock()
        popen_instance.communicate.side_effect = OSError
        popen_instance.__enter__ = mocker.Mock(return_value=popen_instance)
        popen_instance.__exit__ = mocker.Mock(return_value=None)
        mocker.patch(
            "diff_cover.command_runner.subprocess.Popen", return_value=popen_instance
        )

    def test_quality_reporter(self, mocker):
        mock_stderr = mocker.patch("sys.stderr", new_callable=StringIO)
        mocker.patch(
            "diff_cover.violationsreporters.base.run_command_for_code", return_value=0
        )
        reporter = QualityReporter(pycodestyle_driver)
        with pytest.raises(OSError):
            reporter.violations("path/to/file.py")

        assert mock_stderr.getvalue() == "pycodestyle path/to/file.py"

    @pytest.mark.disable_all_files_exist
    def test_file_does_not_exist(self):
        quality = QualityReporter(pycodestyle_driver)
        file_paths = ["ajshdjlasdhajksdh.py"]
        # Expect that we get no results because that file does not exist
        for path in file_paths:
            result = quality.violations(path)
            assert result == []


class TestCppcheckQualityDriverTest:
    """Tests for cppcheck quality driver."""

    def test_parse_report(self):
        """Basic report test parse"""
        expected_violations = {
            "src/foo.c": Violation(
                123,
                "(error) Array 'yolo[4]' accessed at index 4, which is out of bounds.",
            ),
        }
        report = "[src/foo.c:123]: (error) Array 'yolo[4]' accessed at index 4, which is out of bounds."

        driver = CppcheckDriver()
        actual_violations = driver.parse_reports([report])
        assert len(actual_violations) == len(expected_violations)
        for expected in expected_violations:
            assert expected in actual_violations


class TestMypyQualityDriverTest:
    """Tests for mypy quality driver."""

    def test_quality(self, process_patcher):
        """Integration test."""
        process_patcher(
            (
                'foo/bar.py:6: error: "int" has no attribute "upper"  [attr-defined]',
                "",
            )
        )

        expected_violations = [
            Violation(
                line=6,
                message='error: "int" has no attribute "upper"  [attr-defined]',
            ),
        ]

        quality = QualityReporter(mypy_driver)
        actual_violations = quality.violations("foo/bar.py")

        assert quality.name() == "mypy"
        assert actual_violations == expected_violations


class TestRuffCheckQualityDriverTest:
    """Tests for ruff check quality driver."""

    def test_quality(self, process_patcher):
        """Integration test."""
        process_patcher(
            (
                dedent(
                    """
            foo/bar/path/to/file.py:244:26: F541 [*] f-string without any placeholders
                |
            242 |     ]
            243 |     if len(xml_roots) > 0 and len(lcov_roots) > 0:
            244 |         raise ValueError(f"Mixing LCov and XML reports is not supported yet")
                |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ F541
            245 |     elif len(xml_roots) > 0:
            246 |         coverage = XmlCoverageReporter(xml_roots, src_roots, expand_coverage_report)
                |
                = help: Remove extraneous `f` prefix

            foo/bar/path/to/file.py:132:27: F841 [*] Local variable `e` is assigned to but never used
                |
            130 |             with open(self.diff_file_path, "r") as file:
            131 |                 return file.read()
            132 |         except IOError as e:
                |                           ^ F841
            133 |             raise ValueError(
            134 |                 dedent(
                |
                = help: Remove assignment to unused variable `e`
            """
                )
                .strip()
                .encode("ascii"),
                "",
            )
        )

        expected_violations = [
            Violation(line=244, message="F541 [*] f-string without any placeholders"),
            Violation(
                line=132,
                message="F841 [*] Local variable `e` is assigned to but never used",
            ),
        ]

        quality = QualityReporter(ruff_check_driver)
        actual_violations = quality.violations("foo/bar/path/to/file.py")

        assert quality.name() == "ruff.check"
        assert actual_violations == expected_violations


class TestClangFormatcheckQualityDriverTest:
    """Tests for clang-format quality driver."""

    def test_parse_report(self):
        """Basic report test parse"""
        expected_violations = {
            "src/foo.c": [
                Violation(
                    12,
                    "warning: code should be clang-formatted [-Wclang-format-violations]\n        int  bar;\n           ^",
                ),
            ]
        }
        report = "src/foo.c:12:1: warning: code should be clang-formatted [-Wclang-format-violations]\n        int  bar;\n           ^"

        driver = ClangFormatDriver()
        actual_violations = driver.parse_reports([report])
        assert actual_violations == expected_violations
