# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
# For details: https://github.com/coveragepy/coveragepy/blob/main/NOTICE.txt

"""Tests for coverage.py's code parsing."""

from __future__ import annotations

import ast
import re
import textwrap
from unittest import mock

import pytest

from coverage import env
from coverage.exceptions import NoSource, NotPython
from coverage.parser import PythonParser, is_constant_test_expr

from tests.coveragetest import CoverageTest
from tests.helpers import arcz_to_arcs


class PythonParserTestBase(CoverageTest):
    """Tests for coverage.py's Python code parsing."""

    run_in_temp_dir = False

    def parse_text(self, text: str, exclude: str = "nocover") -> PythonParser:
        """Parse `text` as source, and return the `PythonParser` used."""
        text = textwrap.dedent(text)
        parser = PythonParser(text=text, exclude=exclude)
        parser.parse_source()
        return parser


class PythonParserTest(PythonParserTestBase):
    """Tests of coverage.parser."""

    def test_exit_counts(self) -> None:
        parser = self.parse_text("""\
            # check some basic branch counting
            class Foo:
                def foo(self, a):
                    if a:
                        return 5
                    else:
                        return 7

            class Bar:
                pass
            """)
        assert parser.exit_counts() == {
            2: 1,
            3: 1,
            4: 2,
            5: 1,
            7: 1,
            9: 1,
            10: 1,
        }

    def test_try_except(self) -> None:
        parser = self.parse_text("""\
            try:
                a = 2
            except ValueError:
                a = 4
            except ZeroDivideError:
                a = 6
            except:
                a = 8
            b = 9
            """)
        assert parser.exit_counts() == {
            1: 1,
            2: 1,
            3: 1,
            4: 1,
            5: 1,
            6: 1,
            7: 1,
            8: 1,
            9: 1,
        }

    def test_excluded_classes(self) -> None:
        parser = self.parse_text("""\
            class Foo:
                def __init__(self):
                    pass

            if len([]):     # nocover
                class Bar:
                    pass
            """)
        assert parser.exit_counts() == {2: 1, 3: 1}

    def test_missing_branch_to_excluded_code(self) -> None:
        parser = self.parse_text("""\
            if fooey:
                a = 2
            else:   # nocover
                a = 4
            b = 5
            """)
        assert parser.exit_counts() == {1: 1, 2: 1, 5: 1}
        parser = self.parse_text("""\
            def foo():
                if fooey:
                    a = 3
                else:
                    a = 5
            b = 6
            """)
        assert parser.exit_counts() == {1: 1, 2: 2, 3: 1, 5: 1, 6: 1}
        parser = self.parse_text("""\
            def foo():
                if fooey:
                    a = 3
                else:   # nocover
                    a = 5
            b = 6
            """)
        assert parser.exit_counts() == {1: 1, 2: 1, 3: 1, 6: 1}

    @pytest.mark.parametrize(
        "text",
        [
            pytest.param("0 spaces\n  2\n 1", id="bad_indent"),
            pytest.param("'''", id="string_eof"),
            pytest.param("$hello", id="dollar"),
            # on 3.10 this passes ast.parse but fails on tokenize.generate_tokens
            pytest.param(
                "\r'\\\n'''",
                id="leading_newline_eof",
                marks=[
                    pytest.mark.skipif(env.PYVERSION >= (3, 12), reason="parses fine in 3.12"),
                ],
            ),
        ],
    )
    def test_not_python(self, text: str) -> None:
        msg = r"Couldn't parse '<code>' as Python source: ['\"].*['\"] at line \d+"
        with pytest.raises(NotPython, match=msg):
            _ = self.parse_text(text)

    def test_empty_decorated_function(self) -> None:
        parser = self.parse_text("""\
            def decorator(func):
                return func

            @decorator
            def foo(self):
                '''Docstring'''

            @decorator
            def bar(self):
                pass
            """)

        assert parser.statements == {1, 2, 4, 5, 8, 9, 10}
        assert parser.arcs() == set(arcz_to_arcs("14 45 58 89 9.  2.  A-8"))
        assert parser.exit_counts() == {1: 1, 2: 1, 4: 1, 5: 1, 8: 1, 9: 1, 10: 1}

    def test_nested_context_managers(self) -> None:
        # https://github.com/coveragepy/coveragepy/issues/1876
        parser = self.parse_text("""\
            a = 1
            with suppress(ValueError):
                with suppress(ValueError):
                    x = 4
                with suppress(ValueError):
                    x = 6
                with suppress(ValueError):
                    x = 8
            a = 9
            """)

        one_nine = set(range(1, 10))
        assert parser.statements == one_nine
        assert parser.exit_counts() == dict.fromkeys(one_nine, 1)

    def test_module_docstrings(self) -> None:
        parser = self.parse_text("""\
            '''The docstring on line 1'''
            a = 2
            """)
        assert {2} == parser.statements

        parser = self.parse_text("""\
            # Docstring is not line 1
            '''The docstring on line 2'''
            a = 3
            """)
        assert {3} == parser.statements

    def test_fuzzed_double_parse(self) -> None:
        # https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=50381
        # The second parse used to raise `TypeError: 'NoneType' object is not iterable`
        msg = (
            r"(EOF in multi-line statement)"  # before 3.12.0b1
            + r"|(unmatched ']')"  # after 3.12.0b1
        )
        with pytest.raises(NotPython, match=msg):
            self.parse_text("]")
        with pytest.raises(NotPython, match=msg):
            self.parse_text("]")

    def test_bug_1891(self) -> None:
        # These examples exercise code paths I thought were impossible.
        parser = self.parse_text("""\
            res = siblings(
                'configure',
                **ca,
            )
            """)
        assert parser.exit_counts() == {1: 1}
        # In conftest.py, we silence the SyntaxWarning this code causes. If
        # we remove this code, we can probably remove that warning.
        parser = self.parse_text("""\
            def g2():
                try:
                    return 2
                finally:
                    return 3
            """)
        assert parser.exit_counts() == {1: 1, 2: 1, 3: 1, 5: 1}

    def test_fuzzed_weirdness(self) -> None:
        # This used to cause a `min of empty sequence` error:
        self.parse_text("\r\\\n\n\t\t\\\n\n")


class ExclusionParserTest(PythonParserTestBase):
    """Tests for the exclusion code in PythonParser."""

    def test_simple(self) -> None:
        parser = self.parse_text(
            """\
            a = 1; b = 2

            if len([]):
                a = 4   # nocover
            """,
        )
        assert parser.statements == {1, 3}

    def test_excluding_if_suite(self) -> None:
        parser = self.parse_text(
            """\
            a = 1; b = 2

            if len([]):     # nocover
                a = 4
                b = 5
                c = 6
            assert a == 1 and b == 2
            """,
        )
        assert parser.statements == {1, 7}

    def test_excluding_if_but_not_else_suite(self) -> None:
        parser = self.parse_text(
            """\
            a = 1; b = 2

            if len([]):     # nocover
                a = 4
                b = 5
                c = 6
            else:
                a = 8
                b = 9
            assert a == 8 and b == 9
            """,
        )
        assert parser.statements == {1, 8, 9, 10}

    def test_excluding_else_suite(self) -> None:
        parser = self.parse_text(
            """\
            a = 1; b = 2

            if 1==1:
                a = 4
                b = 5
                c = 6
            else:          # nocover
                a = 8
                b = 9
            assert a == 4 and b == 5 and c == 6
            """,
        )
        assert parser.statements == {1, 3, 4, 5, 6, 10}
        parser = self.parse_text(
            """\
            a = 1; b = 2

            if 1==1:
                a = 4
                b = 5
                c = 6

            # Lots of comments to confuse the else handler.
            # more.

            else:          # nocover

            # Comments here too.

                a = 8
                b = 9
            assert a == 4 and b == 5 and c == 6
            """,
        )
        assert parser.statements == {1, 3, 4, 5, 6, 17}

    def test_excluding_oneline_if(self) -> None:
        parser = self.parse_text(
            """\
            def foo():
                a = 2
                if len([]): x = 3       # nocover
                b = 4

            foo()
            """,
        )
        assert parser.statements == {1, 2, 4, 6}

    def test_excluding_a_colon_not_a_suite(self) -> None:
        parser = self.parse_text(
            """\
            def foo():
                l = list(range(10))
                a = l[:3]   # nocover
                b = 4

            foo()
            """,
        )
        assert parser.statements == {1, 2, 4, 6}

    def test_excluding_for_suite(self) -> None:
        parser = self.parse_text(
            """\
            a = 0
            for i in [1,2,3,4,5]:     # nocover
                a += i
            assert a == 15
            """,
        )
        assert parser.statements == {1, 4}
        parser = self.parse_text(
            """\
            a = 0
            for i in [1,
                2,3,4,
                5]:                # nocover
                a += i
            assert a == 15
            """,
        )
        assert parser.statements == {1, 6}
        parser = self.parse_text(
            """\
            a = 0
            for i in [1,2,3,4,5
                ]:                        # nocover
                a += i
                break
                a = 99
            assert a == 1
            """,
        )
        assert parser.statements == {1, 7}

    def test_excluding_for_else(self) -> None:
        parser = self.parse_text(
            """\
            a = 0
            for i in range(5):
                a += i+1
                break
            else:               # nocover
                a = 123
            assert a == 1
            """,
        )
        assert parser.statements == {1, 2, 3, 4, 7}

    def test_excluding_while(self) -> None:
        parser = self.parse_text(
            """\
            a = 3; b = 0
            while a*b:           # nocover
                b += 1
                break
            assert a == 3 and b == 0
            """,
        )
        assert parser.statements == {1, 5}
        parser = self.parse_text(
            """\
            a = 3; b = 0
            while (
                a*b
                ):           # nocover
                b += 1
                break
            assert a == 3 and b == 0
            """,
        )
        assert parser.statements == {1, 7}

    def test_excluding_while_else(self) -> None:
        parser = self.parse_text(
            """\
            a = 3; b = 0
            while a:
                b += 1
                break
            else:           # nocover
                b = 123
            assert a == 3 and b == 1
            """,
        )
        assert parser.statements == {1, 2, 3, 4, 7}

    def test_excluding_try_except(self) -> None:
        parser = self.parse_text(
            """\
            a = 0
            try:
                a = 1
            except:           # nocover
                a = 99
            assert a == 1
            """,
        )
        assert parser.statements == {1, 2, 3, 6}
        parser = self.parse_text(
            """\
            a = 0
            try:
                a = 1
                raise Exception("foo")
            except:
                a = 99
            assert a == 99
            """,
        )
        assert parser.statements == {1, 2, 3, 4, 5, 6, 7}
        parser = self.parse_text(
            """\
            a = 0
            try:
                a = 1
                raise Exception("foo")
            except ImportError:    # nocover
                a = 99
            except:
                a = 123
            assert a == 123
            """,
        )
        assert parser.statements == {1, 2, 3, 4, 7, 8, 9}

    def test_excluding_if_pass(self) -> None:
        # From a comment on the coverage.py page by Michael McNeil Forbes:
        parser = self.parse_text(
            """\
            def f():
                if False:    # pragma: nocover
                    pass     # This line still reported as missing
                if False:    # pragma: nocover
                    x = 1    # Now it is skipped.

            f()
            """,
        )
        assert parser.statements == {1, 7}

    def test_multiline_if_no_branch(self) -> None:
        # From https://github.com/coveragepy/coveragepy/issues/754
        parser = self.parse_text(
            """\
            if (this_is_a_verylong_boolean_expression == True   # pragma: no branch
                and another_long_expression and here_another_expression):
                do_something()
            """,
        )
        parser2 = self.parse_text(
            """\
            if this_is_a_verylong_boolean_expression == True and another_long_expression \\
                and here_another_expression:  # pragma: no branch
                do_something()
            """,
        )
        assert parser.statements == parser2.statements == {1, 3}
        pragma_re = ".*pragma: no branch.*"
        assert parser.lines_matching(pragma_re) == parser2.lines_matching(pragma_re)

    def test_excluding_function(self) -> None:
        parser = self.parse_text(
            """\
            def fn(foo):      # nocover
                a = 1
                b = 2
                c = 3

            x = 1
            assert x == 1
            """,
        )
        assert parser.statements == {6, 7}
        parser = self.parse_text(
            """\
            a = 0
            def very_long_function_to_exclude_name(very_long_argument1,
            very_long_argument2):
                pass
            assert a == 0
            """,
            exclude="function_to_exclude",
        )
        assert parser.statements == {1, 5}
        parser = self.parse_text(
            """\
            a = 0
            def very_long_function_to_exclude_name(
                very_long_argument1,
                very_long_argument2
            ):
                pass
            assert a == 0
            """,
            exclude="function_to_exclude",
        )
        assert parser.statements == {1, 7}
        # linters and formatters can't agree about whether strings are too
        # long, so format in the long variable names that make them too long.
        long_arg = "super_long_input_argument"
        parser = self.parse_text(
            f"""\
            def my_func(
                {long_arg}_0=0,
                {long_arg}_1=1,
                {long_arg}_2=2):
                pass

            def my_func_2({long_arg}_0=0, {long_arg}_1=1, {long_arg}_2=2):
                pass
            """,
            exclude="my_func",
        )
        assert parser.statements == set()
        parser = self.parse_text(
            f"""\
            def my_func(
                {long_arg}_0=0,
                {long_arg}_1=1,
                {long_arg}_2=2):
                pass

            def my_func_2({long_arg}_0=0, {long_arg}_1=1, {long_arg}_2=2):
                pass
            """,
            exclude="my_func_2",
        )
        assert parser.statements == {1, 5}
        parser = self.parse_text(
            f"""\
            def my_func       (
                {long_arg}_0=0,
                {long_arg}_1=1,
                {long_arg}_2=2):
                pass

            def my_func_2    ({long_arg}_0=0, {long_arg}_1=1, {long_arg}_2=2):
                pass
            """,
            exclude="my_func_2",
        )
        assert parser.statements == {1, 5}
        parser = self.parse_text(
            f"""\
            def my_func       (
                {long_arg}_0=0,
                {long_arg}_1=1,
                {long_arg}_2=2):
                pass

            def my_func_2    ({long_arg}_0=0, {long_arg}_1=1, {long_arg}_2=2):
                pass
            """,
            exclude="my_func",
        )
        assert parser.statements == set()
        parser = self.parse_text(
            f"""\
            def my_func \
                (
                {long_arg}_0=0,
                {long_arg}_1=1
                ):
                pass

            def my_func_2({long_arg}_0=0, {long_arg}_1=1, {long_arg}_2=2):
                pass
            """,
            exclude="my_func_2",
        )
        assert parser.statements == {1, 5}
        parser = self.parse_text(
            f"""\
            def my_func \
                (
                {long_arg}_0=0,
                {long_arg}_1=1
                ):
                pass

            def my_func_2({long_arg}_0=0, {long_arg}_1=1, {long_arg}_2=2):
                pass
            """,
            exclude="my_func",
        )
        assert parser.statements == set()

    def test_excluding_bug1713(self) -> None:
        parser = self.parse_text(
            """\
            print("1")

            def hello_3(a):  # pragma: nocover
                match a:
                    case ("5"
                            | "6"):
                        print("7")
                    case "8":
                        print("9")

            print("11")
            """,
        )
        assert parser.statements == {1, 11}
        parser = self.parse_text(
            """\
            print("1")

            def hello_3(a):  # nocover
                if ("4" or
                    "5"):
                    print("6")
                else:
                    print("8")

            print("10")
            """,
        )
        assert parser.statements == {1, 10}
        parser = self.parse_text(
            """\
            print(1)

            def func(a, b):
                if a == 4:      # nocover
                    func5()
                    if b:
                        print(7)
                    func8()

            print(10)
            """,
        )
        assert parser.statements == {1, 3, 10}
        parser = self.parse_text(
            """\
            class Foo:  # pragma: nocover
                def greet(self):
                    print("hello world")
            """,
        )
        assert parser.statements == set()

    def test_excluding_method(self) -> None:
        parser = self.parse_text(
            """\
            class Fooey:
                def __init__(self):
                    self.a = 1

                def foo(self):     # nocover
                    return self.a

            x = Fooey()
            assert x.a == 1
            """,
        )
        assert parser.statements == {1, 2, 3, 8, 9}
        parser = self.parse_text(
            """\
            class Fooey:
                def __init__(self):
                    self.a = 1

                def very_long_method_to_exclude_name(
                    very_long_argument1,
                    very_long_argument2
                ):
                    pass

            x = Fooey()
            assert x.a == 1
            """,
            exclude="method_to_exclude",
        )
        assert parser.statements == {1, 2, 3, 11, 12}

    def test_excluding_class(self) -> None:
        parser = self.parse_text(
            """\
            class Fooey:            # nocover
                def __init__(self):
                    self.a = 1

                def foo(self):
                    return self.a

            x = 1
            assert x == 1
            """,
        )
        assert parser.statements == {8, 9}

    def test_excludes_non_ascii(self) -> None:
        parser = self.parse_text(
            """\
            # coding: utf-8
            a = 1; b = 2

            if len([]):
                a = 5   # ✘cover
            """,
            exclude="✘cover",
        )
        assert parser.statements == {2, 4}

    def test_no_exclude_at_all(self) -> None:
        parser = self.parse_text(
            """\
            def foo():
                if fooey:
                    a = 3
                else:
                    a = 5
            b = 6
            """,
            exclude="",
        )
        assert parser.exit_counts() == {1: 1, 2: 2, 3: 1, 5: 1, 6: 1}

    def test_formfeed(self) -> None:
        # https://github.com/coveragepy/coveragepy/issues/461
        parser = self.parse_text(
            """\
            x = 1
            assert len([]) == 0, (
                "This won't happen %s" % ("hello",)
            )
            \f
            x = 6
            assert len([]) == 0, (
                "This won't happen %s" % ("hello",)
            )
            """,
            exclude="assert",
        )
        assert parser.statements == {1, 6}

    def test_decorator_pragmas(self) -> None:
        parser = self.parse_text("""\
            # 1

            @foo(3)                     # nocover
            @bar
            def func(x, y=5):
                return 6

            class Foo:      # this is the only statement.
                '''9'''
                @foo                    # nocover
                def __init__(self):
                    '''12'''
                    return 13

                @foo(                   # nocover
                    16,
                    17,
                )
                def meth(self):
                    return 20

            @foo(                       # nocover
                23
            )
            def func(x=25):
                return 26
            """)
        raw_statements = {3, 4, 5, 6, 8, 9, 10, 11, 13, 15, 16, 17, 19, 20, 22, 23, 25, 26}
        assert parser.raw_statements == raw_statements
        assert parser.statements == {8}

    def test_decorator_pragmas_with_colons(self) -> None:
        # A colon in a decorator expression would confuse the parser,
        # ending the exclusion of the decorated function.
        parser = self.parse_text("""\
            @decorate(X)        # nocover
            @decorate("Hello"[2])
            def f():
                x = 4

            @decorate(X)        # nocover
            @decorate("Hello"[:7])
            def g():
                x = 9
            """)
        raw_statements = {1, 2, 3, 4, 6, 7, 8, 9}
        assert parser.raw_statements == raw_statements
        assert parser.statements == set()

    def test_class_decorator_pragmas(self) -> None:
        parser = self.parse_text("""\
            class Foo(object):
                def __init__(self):
                    self.x = 3

            @foo                        # nocover
            class Bar(object):
                def __init__(self):
                    self.x = 8
            """)
        assert parser.raw_statements == {1, 2, 3, 5, 6, 7, 8}
        assert parser.statements == {1, 2, 3}

    def test_over_exclusion_bug1779(self) -> None:
        # https://github.com/coveragepy/coveragepy/issues/1779
        parser = self.parse_text("""\
            import abc

            class MyProtocol:               # nocover 3
                @abc.abstractmethod         # nocover 4
                def my_method(self) -> int:
                    ...     # 6

            def function() -> int:
                return 9
            """)
        assert parser.raw_statements == {1, 3, 4, 5, 6, 8, 9}
        assert parser.statements == {1, 8, 9}

    def test_multiline_exclusion_single_line(self) -> None:
        regex = r"print\('.*'\)"
        parser = self.parse_text(
            """\
            def foo():
                print('Hello, world!')
            """,
            regex,
        )
        assert parser.lines_matching(regex) == {2}
        assert parser.raw_statements == {1, 2}
        assert parser.statements == {1}

    def test_multiline_exclusion_suite(self) -> None:
        # A multi-line exclusion that matches a colon line still excludes the entire block.
        regex = r"if T:\n\s+print\('Hello, world!'\)"
        parser = self.parse_text(
            """\
            def foo():
                if T:
                    print('Hello, world!')
                    print('This is a multiline regex test.')
            a = 5
            """,
            regex,
        )
        assert parser.lines_matching(regex) == {2, 3}
        assert parser.raw_statements == {1, 2, 3, 4, 5}
        assert parser.statements == {1, 5}

    def test_multiline_exclusion_no_match(self) -> None:
        regex = r"nonexistent"
        parser = self.parse_text(
            """\
            def foo():
                print('Hello, world!')
            """,
            regex,
        )
        assert parser.lines_matching(regex) == set()
        assert parser.raw_statements == {1, 2}
        assert parser.statements == {1, 2}

    def test_multiline_exclusion_no_source(self) -> None:
        regex = r"anything"
        parser = PythonParser(text="", filename="dummy.py", exclude=regex)
        assert parser.lines_matching(regex) == set()
        assert parser.raw_statements == set()
        assert parser.statements == set()

    def test_multiline_exclusion_all_lines_must_match(self) -> None:
        # https://github.com/coveragepy/coveragepy/issues/996
        regex = r"except ValueError:\n\s*print\('false'\)"
        parser = self.parse_text(
            """\
            try:
                a = 2
                print('false')
            except ValueError:
                print('false')
            except ValueError:
                print('something else')
            except IndexError:
                print('false')
            """,
            regex,
        )
        assert parser.lines_matching(regex) == {4, 5}
        assert parser.raw_statements == {1, 2, 3, 4, 5, 6, 7, 8, 9}
        assert parser.statements == {1, 2, 3, 6, 7, 8, 9}

    def test_multiline_exclusion_multiple_matches(self) -> None:
        regex = r"print\('.*'\)\n\s+. = \d"
        parser = self.parse_text(
            """\
            def foo():
                print('Hello, world!')
                a = 5
            def bar():
                print('Hello again!')
                b = 6
            """,
            regex,
        )
        assert parser.lines_matching(regex) == {2, 3, 5, 6}
        assert parser.raw_statements == {1, 2, 3, 4, 5, 6}
        assert parser.statements == {1, 4}

    def test_multiline_exclusion_suite2(self) -> None:
        regex = r"print\('Hello, world!'\)\n\s+if T:"
        parser = self.parse_text(
            """\
            def foo():
                print('Hello, world!')
                if T:
                    print('This is a test.')
            """,
            regex,
        )
        assert parser.lines_matching(regex) == {2, 3}
        assert parser.raw_statements == {1, 2, 3, 4}
        assert parser.statements == {1}

    def test_multiline_exclusion_match_all(self) -> None:
        regex = (
            r"def foo\(\):\n\s+print\('Hello, world!'\)\n"
            + r"\s+if T:\n\s+print\('This is a test\.'\)"
        )
        parser = self.parse_text(
            """\
            def foo():
                print('Hello, world!')
                if T:
                    print('This is a test.')
            """,
            regex,
        )
        assert parser.lines_matching(regex) == {1, 2, 3, 4}
        assert parser.raw_statements == {1, 2, 3, 4}
        assert parser.statements == set()

    def test_multiline_exclusion_block(self) -> None:
        # https://github.com/coveragepy/coveragepy/issues/1803
        regex = "# no cover: start(?s:.)*?# no cover: stop"
        parser = self.parse_text(
            """\
            a = my_function1()
            if debug:
                msg = "blah blah"
                # no cover: start
                log_message(msg, a)
                b = my_function2()
                # no cover: stop
                count_this()
                # no cover: start
                but_not_this()
                # no cover: stop
            """,
            regex,
        )
        assert parser.lines_matching(regex) == {4, 5, 6, 7, 9, 10, 11}
        assert parser.raw_statements == {1, 2, 3, 5, 6, 8, 10}
        assert parser.statements == {1, 2, 3, 8}

    def test_multiline_exclusion_block2(self) -> None:
        # https://github.com/coveragepy/coveragepy/issues/1797
        regex = r"case _:\n\s+assert_never\("
        parser = self.parse_text(
            """\
            match something:
                case type_1():
                    logic_1()
                case type_2():
                    logic_2()
                case _:
                    assert_never(something)
            match something:
                case type_1():
                    logic_1()
                case type_2():
                    logic_2()
                case _:
                    print("Default case")
            """,
            regex,
        )
        assert parser.lines_matching(regex) == {6, 7}
        assert parser.raw_statements == {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}
        assert parser.statements == {1, 2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 14}

    def test_multiline_exclusion_block3(self) -> None:
        # https://github.com/coveragepy/coveragepy/issues/1741
        # This will only work if there's exactly one return statement in the rest of the function
        regex = r"# no cover: to return(?s:.)*?return"
        parser = self.parse_text(
            """\
            def my_function(args, j):
                if args.command == Commands.CMD.value:
                    return cmd_handler(j, args)
                # no cover: to return
                print(f"Command '{args.command}' was not handled.", file=sys.stderr)
                parser.print_help(file=sys.stderr)

                return os.EX_USAGE
            print("not excluded")
            """,
            regex,
        )
        assert parser.lines_matching(regex) == {4, 5, 6, 7, 8}
        assert parser.raw_statements == {1, 2, 3, 5, 6, 8, 9}
        assert parser.statements == {1, 2, 3, 9}

    def test_multiline_exclusion_whole_source(self) -> None:
        # https://github.com/coveragepy/coveragepy/issues/118
        regex = r"\A(?s:.*# pragma: exclude file.*)\Z"
        parser = self.parse_text(
            """\
            import coverage
            # pragma: exclude file
            def the_void() -> None:
                if "py" not in __file__:
                    print("Not a Python file.")
                print("Everything here is excluded.")

                return
            print("Excluded too")
            """,
            regex,
        )
        assert parser.lines_matching(regex) == {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
        assert parser.raw_statements == {1, 3, 4, 5, 6, 8, 9}
        assert parser.statements == set()

    def test_multiline_exclusion_from_marker(self) -> None:
        # https://github.com/coveragepy/coveragepy/issues/118
        regex = r"# pragma: rest of file(?s:.)*\Z"
        parser = self.parse_text(
            """\
            import coverage
            # pragma: rest of file
            def the_void() -> None:
                if "py" not in __file__:
                    print("Not a Python file.")
                print("Everything here is excluded.")

                return
            print("Excluded too")
            """,
            regex,
        )
        assert parser.lines_matching(regex) == {2, 3, 4, 5, 6, 7, 8, 9, 10}
        assert parser.raw_statements == {1, 3, 4, 5, 6, 8, 9}
        assert parser.statements == {1}


class ParserMissingArcDescriptionTest(PythonParserTestBase):
    """Tests for PythonParser.missing_arc_description."""

    def test_missing_arc_description(self) -> None:
        # This code is never run, so the actual values don't matter.
        parser = self.parse_text("""\
            if x:
                print(2)
            print(3)

            def func5():
                for x in range(6):
                    if x == 7:
                        break

            def func10():
                while something(11):
                    thing(12)
                more_stuff(13)
            """)
        expected = "line 1 didn't jump to line 2 because the condition on line 1 was never true"
        assert expected == parser.missing_arc_description(1, 2)
        expected = "line 1 didn't jump to line 3 because the condition on line 1 was always true"
        assert expected == parser.missing_arc_description(1, 3)
        expected = (
            "line 6 didn't return from function 'func5' "
            + "because the loop on line 6 didn't complete"
        )
        assert expected == parser.missing_arc_description(6, -5)
        expected = "line 6 didn't jump to line 7 because the loop on line 6 never started"
        assert expected == parser.missing_arc_description(6, 7)
        expected = "line 11 didn't jump to line 12 because the condition on line 11 was never true"
        assert expected == parser.missing_arc_description(11, 12)
        expected = (
            "line 11 didn't jump to line 13 " + "because the condition on line 11 was always true"
        )
        assert expected == parser.missing_arc_description(11, 13)

    def test_missing_arc_descriptions_for_exceptions(self) -> None:
        parser = self.parse_text("""\
            try:
                pass
            except ZeroDivideError:
                print("whoops")
            except ValueError:
                print("yikes")
            """)
        expected = (
            "line 3 didn't jump to line 4 " + "because the exception caught by line 3 didn't happen"
        )
        assert expected == parser.missing_arc_description(3, 4)
        expected = (
            "line 5 didn't jump to line 6 " + "because the exception caught by line 5 didn't happen"
        )
        assert expected == parser.missing_arc_description(5, 6)


class MatchCaseMissingArcDescriptionTest(PythonParserTestBase):
    """Missing arc descriptions for match/case."""

    def test_match_case(self) -> None:
        parser = self.parse_text("""\
            match command.split():
                case ["go", direction] if direction in "nesw":      # 2
                    match = f"go: {direction}"
                case ["go", _]:                                     # 4
                    match = "no go"
            print(match)                                            # 6
            """)
        assert parser.missing_arc_description(2, 3) == (
            "line 2 didn't jump to line 3 because the pattern on line 2 never matched"
        )
        assert parser.missing_arc_description(2, 4) == (
            "line 2 didn't jump to line 4 because the pattern on line 2 always matched"
        )
        assert parser.missing_arc_description(4, 6) == (
            "line 4 didn't jump to line 6 because the pattern on line 4 always matched"
        )

    def test_final_wildcard(self) -> None:
        parser = self.parse_text("""\
            match command.split():
                case ["go", direction] if direction in "nesw":      # 2
                    match = f"go: {direction}"
                case _:                                             # 4
                    match = "no go"
            print(match)                                            # 6
            """)
        assert parser.missing_arc_description(2, 3) == (
            "line 2 didn't jump to line 3 because the pattern on line 2 never matched"
        )
        assert parser.missing_arc_description(2, 4) == (
            "line 2 didn't jump to line 4 because the pattern on line 2 always matched"
        )
        # 4-6 isn't a possible arc, so the description is generic.
        assert parser.missing_arc_description(4, 6) == "line 4 didn't jump to line 6"

    def test_missing_arc_descriptions_bug1775(self) -> None:
        # Bug: the `if x == 2:` line was marked partial with a message of:
        # line 6 didn't return from function 'func', because the return on line 4 wasn't executed
        # At some point it changed to "didn't jump to the function exit," which
        # is close enough. These situations are hard to describe precisely.
        parser = self.parse_text("""\
            def func():
                x = 2
                try:
                    return 4
                finally:
                    if x == 2:  # line 6
                        print("x is 2")

            func()
            """)
        assert parser.missing_arc_description(6, -1) == "line 6 didn't jump to the function exit"


class ParserFileTest(CoverageTest):
    """Tests for coverage.py's code parsing from files."""

    def parse_file(self, filename: str) -> PythonParser:
        """Parse `text` as source, and return the `PythonParser` used."""
        parser = PythonParser(filename=filename, exclude="nocover")
        parser.parse_source()
        return parser

    @pytest.mark.parametrize(
        "slug, newline",
        [
            ("unix", "\n"),
            ("dos", "\r\n"),
            ("mac", "\r"),
        ],
    )
    def test_line_endings(self, slug: str, newline: str) -> None:
        text = """\
            # check some basic branch counting
            class Foo:
                def foo(self, a):
                    if a:
                        return 5
                    else:
                        return 7

            class Bar:
                pass
            """
        counts = {2: 1, 3: 1, 4: 2, 5: 1, 7: 1, 9: 1, 10: 1}
        fname = slug + ".py"
        self.make_file(fname, text, newline=newline)
        parser = self.parse_file(fname)
        assert parser.exit_counts() == counts, f"Wrong for {fname!r}"

    def test_encoding(self) -> None:
        self.make_file(
            "encoded.py",
            """\
            coverage = "\xe7\xf6v\xear\xe3g\xe9"
            """,
        )
        parser = self.parse_file("encoded.py")
        assert parser.exit_counts() == {1: 1}

    def test_missing_line_ending(self) -> None:
        # Test that the set of statements is the same even if a final
        # multi-line statement has no final newline.
        # https://github.com/coveragepy/coveragepy/issues/293

        self.make_file(
            "normal.py",
            """\
            out, err = some_module.some_function(
                ["my data", "-c", "pass"],
                arg1=some_module.NAME,
                arg2=some_module.OTHER_NAME).function()
            """,
        )

        parser = self.parse_file("normal.py")
        assert parser.statements == {1}

        self.make_file(
            "abrupt.py",
            """\
            out, err = some_module.some_function(
                ["my data", "-c", "pass"],
                arg1=some_module.NAME,
                arg2=some_module.OTHER_NAME).function()""",
        )  # no final newline.

        # Double-check that some test helper wasn't being helpful.
        with open("abrupt.py", encoding="utf-8") as f:
            assert f.read()[-1] == ")"

        parser = self.parse_file("abrupt.py")
        assert parser.statements == {1}

    def test_os_error(self) -> None:
        self.make_file("cant-read.py", "BOOM!")
        msg = "No source for code: 'cant-read.py': Fake!"
        with pytest.raises(NoSource, match=re.escape(msg)):
            with mock.patch("coverage.python.read_python_source", side_effect=OSError("Fake!")):
                PythonParser(filename="cant-read.py")


@pytest.mark.parametrize(
    ["expr", "ret"],
    [
        ("True", (True, True)),
        ("False", (True, False)),
        ("1", (True, True)),
        ("0", (True, False)),
        ("__debug__", (True, True)),
        ("not __debug__", (True, False)),
        ("not(__debug__)", (True, False)),
        ("-__debug__", (False, False)),
        ("__debug__ or True", (True, True)),
        ("__debug__ + True", (False, False)),
        ("x", (False, False)),
        ("__debug__ or debug", (False, False)),
    ],
)
def test_is_constant_test_expr(expr: str, ret: tuple[bool, bool]) -> None:
    node = ast.parse(expr, mode="eval").body
    print(ast.dump(node, indent=4))
    assert is_constant_test_expr(node) == ret
