Repository: we-like-parsers/pegen (language: python)

Issue:
Publishing on the PyPI
Pegen's Python parser generator is really cool, and also very [useful](https://github.com/isidentical/pyasdl/blob/master/pyasdl/parser.py) though the version on the PyPI seems not sync with the upstream version under Tools/peg_generator. It is possible now to just clone CPython and install it though not very feasible. Or the other way would be, after generating the parser just hosting `pegen/parser.py` / `pegen/tokenizer.py` under the project (which is what I do now) though it is not a very good solution either. I think it would be really amazing if we host this package on the PyPI (maybe with automating some stuff on the GH actions to clone CPython, check if anything changed under `Tools/peg_generator`, and sync it with here and open an issue to notify the maintainers that they should make a release). If any help needed, I can try to assist. 


Discussion / hints:
This is indeed some of the action items from the core Dev sprint. With all that has been happening it has been impossible to find time for it but I will try to do it soon.

One limitation is that the C generator is not that useful outside C Python because the AST symbols are not exposed, so we can only expose the Python generator.
I agree that exposing only the Python generator is the way to go. It's both less work maintaining it for us and the value of exposing the C generator is really really minimal.
Should we maybe start a new repository and move the version we want to publish there? With all the blog post code and the C generator code here, it feels that a new repo would be much cleaner.
I think it makes sense (to preserve the blog posts / sources)
> Should we maybe start a new repository and move the version we want to publish there? With all the blog post code and the C generator code here, it feels that a new repo would be much cleaner.

Appart from the blog post, is there any reason we want to preserve the rest of the code in this repo? The canonical version of all the code itself lives in CPython now and much if the stuff we have here currently is out of date.
I would also be interested in the Python generator since I maintain a project that defines a superset of Python (https://github.com/nucleic/enaml). Currently it relies on ply which is LALR1 parser and may not be able to keep up with the new language features making use of the full power of the parser.
I renamed the old repo to https://github.com/we-like-parsers/pegen_experiments and I have created https://github.com/we-like-parsers/pegen with some initial scaffolding, also updating the code from CPython
Should I start removing stuff? The way I see it, we probably can remove anything not related to the python generator. `docs`, some of the `scripts` and some of the `tests` can certainly go. We can wrap the thing around a nicer API/command-line utility next and maybe improve the README?
Do I need to fix the links in my blog posts on Medium? Some of those link
to the examples in this repo.

> Do I need to fix the links in my blog posts on Medium? Some of those link to the examples in this repo.

Oh, good point! I will move the files back so the links are still valid.
> Should I start removing stuff? The way I see it, we probably can remove anything not related to the python generator. `docs`, some of the `scripts` and some of the `tests` can certainly go. We can wrap the thing around a nicer API/command-line utility next and maybe improve the README?

I did some general removal already and I prepared the package with some stuff for easier testing of the pypi packages, but feel free to continue improving and cleaning stuff :)
> Do I need to fix the links in my blog posts on Medium? Some of those link to the examples in this repo.

Interesting. I checked the links in your blog and GitHub seems smart enough to redirect to the previous (now renamed) repo!
This has been there since 2013! https://github.blog/2013-05-16-repository-redirects-are-here/
The first release is online since September 7th

Fix patch:
diff --git a/data/python.gram b/data/python.gram
index c3d1aba..fb2cda5 100644
--- a/data/python.gram
+++ b/data/python.gram
@@ -3,6 +3,7 @@
 @class PythonParser
 
 @subheader'''
+import enum
 import io
 import itertools
 import os
@@ -96,6 +97,12 @@ def parse_string(
     return parser.parse(mode if mode == "eval" else "file")
 
 
+class Target(enum.Enum):
+    FOR_TARGETS = enum.auto()
+    STAR_TARGETS = enum.auto()
+    DEL_TARGETS = enum.auto()
+
+
 class Parser(Parser):
 
     #: Name of the source file, used in error reports
@@ -132,7 +139,7 @@ class Parser(Parser):
 
                 res = getattr(self, rule)()
 
-            self.raise_syntax_error_starting_from("invalid syntax", last_token)
+            self.raise_raw_syntax_error("invalid syntax", last_token.start, last_token.end)
 
         return res
 
@@ -147,9 +154,13 @@ class Parser(Parser):
                 f"{error_msg} is only supported in Python {min_version} and above."
             )
 
-    def raise_indentation_error(self, msg) -> None:
+    def raise_indentation_error(self, msg: str) -> None:
         """Raise an indentation error."""
-        raise IndentationError(msg)
+        last_token = self._tokenizer.diagnose()
+        args = (self.filename, last_token.start[0], last_token.start[1] + 1, last_token.line)
+        if sys.version_info >= (3, 10):
+            args += (last_token.end[0], last_token.end[1] + 1)
+        raise IndentationError(msg, args)
 
     def get_expr_name(self, node) -> str:
         """Get a descriptive name for an expression."""
@@ -158,7 +169,14 @@ class Parser(Parser):
         node_t = type(node)
         if node_t is ast.Constant:
             v = node.value
-            if v in (None, True, False, Ellipsis):
+            if v is Ellipsis:
+                return "ellipsis"
+            elif v is None:
+                return str(v)
+            # Avoid treating 1 as True through == comparison
+            elif v is True:
+                return str(v)
+            elif v is False:
                 return str(v)
             else:
                 return "literal"
@@ -171,59 +189,94 @@ class Parser(Parser):
                 f"(line {node.lineno})."
             )
 
+    def get_invalid_target(self, target: Target, node: Optional[ast.AST]) -> Optional[ast.AST]:
+        """Get the meaningful invalid target for different assignment type."""
+        if node is None:
+            return None
+
+        # We only need to visit List and Tuple nodes recursively as those
+        # are the only ones that can contain valid names in targets when
+        # they are parsed as expressions. Any other kind of expression
+        # that is a container (like Sets or Dicts) is directly invalid and
+        # we do not need to visit it recursively.
+        if isinstance(node, (ast.List, ast.Tuple)):
+            for e in node.elts:
+                if (inv := self.get_invalid_target(target, e)) is not None:
+                    return inv
+        elif isinstance(node, ast.Starred):
+            if target is Target.DEL_TARGETS:
+                return node
+            return self.get_invalid_target(target, node.value)
+        elif isinstance(node, ast.Compare):
+            # This is needed, because the `a in b` in `for a in b` gets parsed
+            # as a comparison, and so we need to search the left side of the comparison
+            # for invalid targets.
+            if target is Target.FOR_TARGETS:
+                if isinstance(node.ops[0], ast.In):
+                    return self.get_invalid_target(target, node.left)
+                return None
+
+            return node
+        elif isinstance(node, (ast.Name, ast.Subscript, ast.Attribute)):
+            return None
+        else:
+            return node
+
     def set_expr_context(self, node, context):
         """Set the context (Load, Store, Del) of an ast node."""
         node.ctx = context
         return node
 
-    def ensure_real(self, number_str: str):
-        number = ast.literal_eval(number_str)
-        if type(number) is complex:
-            self.raise_syntax_error("real number required in complex literal")
-        return number
+    def ensure_real(self, number: ast.Constant):
+        value = ast.literal_eval(number.string)
+        if type(value) is complex:
+            self.raise_syntax_error_known_location("real number required in complex literal", number)
+        return value
 
-    def ensure_imaginary(self, number_str: str):
-        number = ast.literal_eval(number_str)
-        if type(number) is not complex:
-            self.raise_syntax_error("imaginary number required in complex literal")
-        return number
+    def ensure_imaginary(self, number:  ast.Constant):
+        value = ast.literal_eval(number.string)
+        if type(value) is not complex:
+            self.raise_syntax_error_known_location("imaginary number required in complex literal", number)
+        return value
 
     def generate_ast_for_string(self, tokens):
         """Generate AST nodes for strings."""
-        err_msg = ''
-        line = 1
+        err_args = None
+        line_offset = tokens[0].start[0]
+        line = line_offset
         col_offset = 0
-        source = ''
+        source = "(\\n"
         for t in tokens:
             n_line = t.start[0] - line
             if n_line:
                 col_offset = 0
-            source += """\n""" * n_line + ' ' * (t.start[1] - col_offset) + t.string
+            source += """\\n""" * n_line + ' ' * (t.start[1] - col_offset) + t.string
             line, col_offset = t.end
-        if source[0] == ' ':
-            source = '(' + source[1:]
-        else:
-            source = '(' + source
-        source += ')'
+        source += "\\n)"
         try:
             m = ast.parse(source)
-        except SyntaxError as e:
-            err_msg = e.args[0]
-            # Identify the line at which the error occurred to get a more
-            # accurate line number
-            for t in tokens:
-                try:
-                    m = ast.parse(t.string)
-                except SyntaxError:
-                    break
+        except SyntaxError as err:
+            args = (err.filename, err.lineno + line_offset - 2, err.offset, err.text)
+            if sys.version_info >= (3, 10):
+                args += (err.end_lineno + line_offset - 2, err.end_offset)
+            err_args = (err.msg, args)
+            # Ensure we do not keep the frame alive longer than necessary
+            # by explicitely deleting the error once we got what we needed out
+            # of it
+            del err
 
         # Avoid getting a triple nesting in the error report that does not
         # bring anything relevant to the traceback.
-        if err_msg:
-            self.raise_syntax_error_known_location(err_msg, t)
-            raise self._exception
-
-        return m.body[0].value
+        if err_args is not None:
+            raise SyntaxError(*err_args)
+
+        node = m.body[0].value
+        # Since we asked Python to parse an alterred source starting at line 2
+        # we alter the lineno of the returned AST to recover the right line.
+        # If the string start at line 1, tha AST says 2 so we need to decrement by 1
+        # hence the -2.
+        ast.increment_lineno(node, line_offset - 2)
+        return node
 
     def extract_import_level(self, tokens: List[tokenize.TokenInfo]) -> int:
         """Extract the relative import level from the tokens preceding the module name.
@@ -317,27 +370,42 @@ class Parser(Parser):
                 self._tokenizer.get_lines(list(range(start[0], end[0] + 1)))
             )
 
-        return  SyntaxError(
-            message,
-            (self.filename, start[0], start[1], line)
-        )
+        # tokenize.py index column offset from 0 while Cpython index column
+        # offset at 1 when reporting SyntaxError, so we need to increment
+        # the column offset when reporting the error.
+        args = (self.filename, start[0], start[1] + 1, line)
+        if sys.version_info >= (3, 10):
+            args += (end[0], end[1] + 1)
+
+        return SyntaxError(message, args)
 
     def raise_raw_syntax_error(
         self,
         message: str,
         start: Optional[Tuple[int, int]] = None,
         end: Optional[Tuple[int, int]] = None
-    ) -> None:
+    ) -> NoReturn:
         raise self._build_syntax_error(message, start, end)
 
     def make_syntax_error(self, message: str) -> None:
         return self._build_syntax_error(message)
 
-    def raise_syntax_error(self, message: str) -> None:
+    def expect_forced(self, res: Any, expectation: str) -> Optional[tokenize.TokenInfo]:
+        if res is None:
+            last_token = self._tokenizer.diagnose()
+            self.raise_raw_syntax_error(
+                f"expected {expectation}", last_token.start, last_token.start
+            )
+        return res
+
+    def raise_syntax_error(self, message: str) -> NoReturn:
         """Raise a syntax error."""
-        raise self._build_syntax_error(message)
+        tok = self._tokenizer.diagnose()
+        raise self._build_syntax_error(message, tok.start, tok.end if tok.type != 4 else tok.start)
 
-    def raise_syntax_error_known_location(self, message: str, node) -> None:
+    def raise_syntax_error_known_location(
+        self, message: str, node: Union[ast.AST, tokenize.TokenInfo]
+    ) -> NoReturn:
         """Raise a syntax error that occured at a given AST node."""
         if isinstance(node, tokenize.TokenInfo):
             start = node.start
@@ -353,7 +421,7 @@ class Parser(Parser):
         message: str,
         start_node: Union[ast.AST, tokenize.TokenInfo],
         end_node: Union[ast.AST, tokenize.TokenInfo]
-    ) -> None:
+    ) -> NoReturn:
         if isinstance(start_node, tokenize.TokenInfo):
             start = start_node.start
         else:
@@ -370,13 +438,30 @@ class Parser(Parser):
         self,
         message: str,
         start_node: Union[ast.AST, tokenize.TokenInfo]
-    ) -> None:
+    ) -> NoReturn:
         if isinstance(start_node, tokenize.TokenInfo):
             start = start_node.start
         else:
             start = start_node.lineno, start_node.col_offset
 
-        raise self._build_syntax_error(message, start, None)
+        last_token = self._tokenizer.diagnose()
+
+        raise self._build_syntax_error(message, start, last_token.start)
+
+    def raise_syntax_error_invalid_target(
+        self, target: Target, node: Optional[ast.AST]
+    ) -> NoReturn:
+        invalid_target = self.get_invalid_target(target, node)
+
+        if invalid_target is None:
+            return None
+
+        if target in (Target.STAR_TARGETS, Target.FOR_TARGETS):
+            msg = f"cannot assign to {self.get_expr_name(invalid_target)}"
+        else:
+            msg = f"cannot delete {self.get_expr_name(invalid_target)}"
+
+        self.raise_syntax_error_known_location(msg, invalid_target)
 
 '''
 
@@ -920,10 +1005,10 @@ signed_real_number:
     | '-' real=real_number { ast.UnaryOp(op=ast.USub(), operand=real, LOCATIONS) }
 
 real_number[ast.Constant]:
-    | real=NUMBER { ast.Constant(value=self.ensure_real(real.string), LOCATIONS) }
+    | real=NUMBER { ast.Constant(value=self.ensure_real(real), LOCATIONS) }
 
 imaginary_number[ast.Constant]:
-    | imag=NUMBER { ast.Constant(value=self.ensure_imaginary(imag.string), LOCATIONS) }
+    | imag=NUMBER { ast.Constant(value=self.ensure_imaginary(imag), LOCATIONS) }
 
 capture_pattern:
     | target=pattern_capture_target {
@@ -1564,7 +1649,9 @@ invalid_arguments[NoReturn]:
      }
     | a=expression b=for_if_clauses ',' [args | expression for_if_clauses] {
         self.raise_syntax_error_known_range(
-            "Generator expression must be parenthesized", a, b[-1].target
+            "Generator expression must be parenthesized",
+            a,
+            (b[-1].ifs[-1] if b[-1].ifs else b[-1].iter)
         )
      }
     | a=NAME b='=' expression for_if_clauses {
@@ -1576,14 +1663,14 @@ invalid_arguments[NoReturn]:
         self.raise_syntax_error_known_range(
             "Generator expression must be parenthesized",
             a[0][-1],
-            b[-1].target
+            (b[-1].ifs[-1] if b[-1].ifs else b[-1].iter),
         ) if len(a[0]) > 1 else None
      }
     | args ',' a=expression b=for_if_clauses {
         self.raise_syntax_error_known_range(
             "Generator expression must be parenthesized",
             a,
-            b[-1].target,
+            (b[-1].ifs[-1] if b[-1].ifs else b[-1].iter),
         )
      }
     | a=args ',' args {
@@ -1646,8 +1733,8 @@ invalid_named_expression[NoReturn]:
         (
             None
             if self.in_recursive_rule else
-            self.raise_syntax_error_known_range(
-                f"cannot assign to {self.get_expr_name(a)} here. Maybe you meant '==' instead of '='?", a, b
+            self.raise_syntax_error_known_location(
+                f"cannot assign to {self.get_expr_name(a)} here. Maybe you meant '==' instead of '='?", a
             )
         )
      }
@@ -1663,14 +1750,14 @@ invalid_assignment[NoReturn]:
     | a=expression ':' expression {
         self.raise_syntax_error_known_location("illegal target for annotation", a) }
     | (star_targets '=')* a=star_expressions '=' {
-        self.raise_syntax_error_known_location(f"cannot assign to {self.get_expr_name(a)}", a)
+        self.raise_syntax_error_invalid_target(Target.STAR_TARGETS, a)
      }
     | (star_targets '=')* a=yield_expr '=' {
         self.raise_syntax_error_known_location("assignment to yield expression not possible", a)
      }
     | a=star_expressions augassign (yield_expr | star_expressions) {
         self.raise_syntax_error_known_location(
-            f"{self.get_expr_name(a)} is an illegal expression for augmented assignment", a
+            f"'{self.get_expr_name(a)}' is an illegal expression for augmented assignment", a
         )
      }
 invalid_ann_assign_target[ast.AST]:
@@ -1679,7 +1766,7 @@ invalid_ann_assign_target[ast.AST]:
     | '(' a=invalid_ann_assign_target ')' { a }
 invalid_del_stmt[NoReturn]:
     | 'del' a=star_expressions {
-        self.raise_syntax_error_known_location(f"cannot delete {self.get_expr_name(a)}", a)
+        self.raise_syntax_error_invalid_target(Target.DEL_TARGETS, a)
      }
 invalid_block[NoReturn]:
     | NEWLINE !INDENT { self.raise_indentation_error("expected an indented block") }
@@ -1730,12 +1817,12 @@ invalid_double_type_comments[NoReturn]:
      }
 invalid_with_item[NoReturn]:
     | expression 'as' a=expression &(',' | ')' | ':') {
-        self.raise_syntax_error_known_location(f"cannot assign to {self.get_expr_name(a)}", a)
+        self.raise_syntax_error_invalid_target(Target.STAR_TARGETS, a)
      }
 
 invalid_for_target[NoReturn]:
     | 'async'? 'for' a=star_expressions {
-        self.raise_syntax_error_known_location(f"cannot assign to {self.get_expr_name(a)}", a)
+        self.raise_syntax_error_invalid_target(Target.FOR_TARGETS, a)
      }
 
 invalid_group[NoReturn]:
@@ -1776,7 +1863,7 @@ invalid_try_stmt[NoReturn]:
      }
 invalid_except_stmt[None]:
     | 'except' a=expression ',' expressions ['as' NAME ] ':' {
-        self.raise_syntax_error_starting_from("exception group must be parenthesized", a)
+        self.raise_syntax_error_starting_from("multiple exception types must be parenthesized", a)
      }
     | a='except' expression ['as' NAME ] NEWLINE { self.raise_syntax_error("expected ':'") }
     | a='except' NEWLINE { self.raise_syntax_error("expected ':'") }
@@ -1894,8 +1981,8 @@ invalid_kvpair[None]:
     | a=expression !(':') {
         self.raise_raw_syntax_error(
             "':' expected after dictionary key",
-            (a.lineno, a.col_offset - 1),
-            (a.end_lineno, a.end_col_offset, -1)
+            (a.lineno, a.col_offset),
+            (a.end_lineno, a.end_col_offset)
         )
      }
     | expression ':' a='*' bitwise_or {


Test patch:
diff --git a/tests/python_parser/test_syntax_error_handling.py b/tests/python_parser/test_syntax_error_handling.py
index be3088b..ce435dd 100644
--- a/tests/python_parser/test_syntax_error_handling.py
+++ b/tests/python_parser/test_syntax_error_handling.py
@@ -4,8 +4,23 @@
 import pytest
 
 
+def parse_invalid_syntax(
+    python_parse_file, python_parse_str, tmp_path, source, exc_cls, message, start, end
+):
 
-def parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exc_cls, message):
+    # Check we obtain the expected error from Python
+    try:
+        exec(source, {}, {})
+    except exc_cls as py_e:
+        py_exc = py_e
+    except Exception as py_e:
+        assert (
+            False
+        ), f"Python produced {py_e.__class__.__name__} instead of {exc_cls.__name__}: {py_e}"
+    else:
+        assert False, f"Python did not throw any exception, expected {exc_cls}"
+
+    # Check our parser raises both from str and file mode.
     with pytest.raises(exc_cls) as e:
         python_parse_str(source, "exec")
 
@@ -19,185 +34,306 @@ def parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source,
     with pytest.raises(exc_cls) as e:
         python_parse_file(str(test_file))
 
+    # Check Python message but do not expect message to match for earlier Python versions
+    if sys.version_info >= (3, 10):
+        # NOTE ugly hack for 1 CPython bug
+        if exc_cls is SyntaxError and e.type is IndentationError:
+            assert message.replace("'", "") in py_exc.args[0]
+        else:
+            assert message in py_exc.args[0]
+
     print(str(e.exconly()))
     assert message in str(e.exconly())
 
+    # Check start/end line/column on Python 3.10
+    if sys.version_info >= (3, 10):
+        for parser, exc in [("Python", py_exc), ("pegen", e.value)]:
+            if (
+                exc.lineno != start[0]
+                or exc.offset != start[1]
+                # Do not check end for indentation errors
+                or (not isinstance(e, IndentationError) and exc.end_lineno != end[0])
+                or (not isinstance(e, IndentationError) and (end[1] is not None and exc.end_offset != end[1]))
+            ):
+                raise ValueError(
+                    f"Expected locations of {start} and {end}, but got "
+                    f"{(exc.lineno, exc.offset)} and {(exc.end_lineno, exc.end_offset)} "
+                    f"from {parser}"
+                )
+
 
 @pytest.mark.parametrize(
-    "source, message", [("f'a = { 1 + }'", "line 1"), ("(\n\t'b'\n\tf'a = { 1 + }'\n)", "line 3")]
+    "source, message, start, end",
+    [
+        ("f'a = { 1 + }'", "f-string: invalid syntax", (1, 7), (1, 8)),
+        ("(\n\t'b'\n\tf'a = { 1 + }'\n)", "f-string: invalid syntax", (3, 7), (3, 8)),
+    ],
 )
-def test_syntax_error_in_str(python_parse_file, python_parse_str, tmp_path, source, message):
+def test_syntax_error_in_str(
+    python_parse_file, python_parse_str, tmp_path, source, message, start, end
+):
     parse_invalid_syntax(
-        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message
+        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message, start, end
     )
 
 
 @pytest.mark.parametrize(
-    "source, message",
+    "source, message, start, end",
     [
-        ("a 1", "invalid syntax. Perhaps you forgot a comma?"),
-        ("2 if 4", "expected 'else' after 'if' expression"),
-        ("a 1 if b else 2", "invalid syntax. Perhaps you forgot a comma?"),
-        ("a 1ambda: 1", "invalid syntax. Perhaps you forgot a comma?"),
-        ("print 1", "Missing parentheses in call to 'print'"),
-        ("exec 1", "Missing parentheses in call to 'exec'"),
-        ("a if b", "expected 'else' after 'if' expression"),
-        ("c = a if b:", "SyntaxError: invalid syntax"),
+        ("(a 1)", "invalid syntax. Perhaps you forgot a comma?", (1, 2), (1, 5)),
+        ("2 if 4", "expected 'else' after 'if' expression", (1, 1), (1, 7)),
+        ("(a 1 if b else 2)", "invalid syntax. Perhaps you forgot a comma?", (1, 2), (1, 17)),
+        ("(a lambda: 1)", "invalid syntax. Perhaps you forgot a comma?", (1, 2), (1, 13)),
+        ("print 1", "Missing parentheses in call to 'print'", (1, 1), (1, 8)),
+        ("exec 1", "Missing parentheses in call to 'exec'", (1, 1), (1, 7)),
+        ("a if b", "expected 'else' after 'if' expression", (1, 1), (1, 7)),
+        ("c = a if b:", "invalid syntax", (1, 11), (1, 12)),
     ],
 )
-def test_invalid_expression(python_parse_file, python_parse_str, tmp_path, source, message):
+def test_invalid_expression(
+    python_parse_file, python_parse_str, tmp_path, source, message, start, end
+):
     parse_invalid_syntax(
-        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message
+        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message, start, end
     )
 
 
 # Those tests are mostly there to get coverage on exiting rules without matching
 @pytest.mark.parametrize(
-    "source, message",
+    "source, message, start, end",
     [
-        ("global a, 1", "invalid syntax"),
-        ("nonlocal a, 1", "invalid syntax"),
-        ("yield raise", "invalid syntax"),
-        ("assert raise", "invalid syntax"),
-        ("return def", "invalid syntax"),
-        ("raise def", "invalid syntax"),
-        ("del raise", "invalid syntax"),
-        ("if raise:\n\tpass", "invalid syntax"),
-        ("@raise\ndef f():\n\tpass", "invalid syntax"),
-        ("a: int = raise", "invalid syntax"),
+        ("global a, 1", "invalid syntax", (1, 11), (1, 12)),
+        ("nonlocal a, 1", "invalid syntax", (1, 13), (1, 14)),
+        ("yield raise", "invalid syntax", (1, 7), (1, 12)),
+        ("assert raise", "invalid syntax", (1, 8), (1, 13)),
+        ("return def", "invalid syntax", (1, 8), (1, 11)),
+        ("raise def", "invalid syntax", (1, 7), (1, 10)),
+        ("del raise", "invalid syntax", (1, 5), (1, 10)),
+        ("if raise:\n\tpass", "invalid syntax", (1, 4), (1, 9)),
+        ("@raise\ndef f():\n\tpass", "invalid syntax", (1, 2), (1, 7)),
+        ("a: int = raise", "invalid syntax", (1, 10), (1, 15)),
     ],
 )
-def test_invalid_statements(python_parse_file, python_parse_str, tmp_path, source, message):
+def test_invalid_statements(
+    python_parse_file, python_parse_str, tmp_path, source, message, start, end
+):
     parse_invalid_syntax(
-        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message
+        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message, start, end
     )
 
 
 @pytest.mark.parametrize(
-    "source, message",
+    "source, message, start, end",
     [
         # Invalid arguments rules
-        ("f(**a, *b)", "iterable argument unpacking follows keyword argument unpacking"),
-        ("f(a for a in b, c)", "Generator expression must be parenthesized"),
-        ("f(a for a in b, c for c in d)", "Generator expression must be parenthesized"),
+        (
+            "f(**a, *b)",
+            "iterable argument unpacking follows keyword argument unpacking",
+            (1, 3),
+            (1, 6),
+        ),
+        # NOTE CPython bug, should report 15 as expected (we use None to omit the check)
+        ("f(a for a in b, c)", "Generator expression must be parenthesized", (1, 3), (1, None)),
+        # NOTE CPython bug, should report 20 as expected (we use None to omit the check)
+        ("f(a for a in b if a, c)", "Generator expression must be parenthesized", (1, 3), (1, None)),
+        # NOTE CPython bug, should report 15 as expected (we use None to omit the check)
+        (
+            "f(a for a in b, c for c in d)",
+            "Generator expression must be parenthesized",
+            (1, 3),
+            (1, None),
+        ),
         (
             "f(a=1 for i in range(10))",
             "invalid syntax. Maybe you meant '==' or ':=' instead of '='?",
-        ),
-        ("f(a, b for b in c)", "Generator expression must be parenthesized"),
-        ("f(a, b for b in c, d)", "Generator expression must be parenthesized"),
-        ("f(**a, b)", "positional argument follows keyword argument unpacking"),
-        ("f(a=1, b)", "positional argument follows keyword argument"),
+            (1, 3),
+            (1, 5),
+        ),
+        # NOTE CPython bug, should report 18 as expected (we use None to omit the check)
+        ("f(a, b for b in c)", "Generator expression must be parenthesized", (1, 6), (1, None)),
+        # NOTE CPython bug, should report 18 as expected (we use None to omit the check)
+        ("f(a, b for b in c, d)", "Generator expression must be parenthesized", (1, 6), (1, None)),
+        ("f(**a, b)", "positional argument follows keyword argument unpacking", (1, 9), (1, 10)),
+        ("f(a=1, b)", "positional argument follows keyword argument", (1, 9), (1, 10)),
         # Invalid kwarg rules
-        ("f(b=c for c in d)", "invalid syntax. Maybe you meant '==' or ':=' instead of '='?"),
-        ("f(1 + b=2)", 'expression cannot contain assignment, perhaps you meant "=="?'),
+        (
+            "f(b=c for c in d)",
+            "invalid syntax. Maybe you meant '==' or ':=' instead of '='?",
+            (1, 3),
+            (1, 5),
+        ),
+        (
+            "f(1 + b=2)",
+            'expression cannot contain assignment, perhaps you meant "=="?',
+            (1, 3),
+            (1, 9),
+        ),
     ],
 )
-def test_invalid_call_arguments(python_parse_file, python_parse_str, tmp_path, source, message):
+def test_invalid_call_arguments(
+    python_parse_file, python_parse_str, tmp_path, source, message, start, end
+):
     parse_invalid_syntax(
-        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message
+        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message, start, end
     )
 
 
 @pytest.mark.parametrize(
-    "source, message",
+    "source, message, start, end",
     [
-        ("'a' = 1", "cannot assign to literal"),
-        ("1 = 1", "cannot assign to 1"),
-        ("True = 1", "cannot assign to True"),
-        ("False = 1", "cannot assign to False"),
-        ("... = 1", "cannot assign to Ellipsis"),
-        ("None = 1", "cannot assign to None"),
-        ("(a, b) : int = (1, 2)", "only single target (not tuple) can be annotated"),
-        ("[a, b] : int = [1, 2]", "only single target (not list) can be annotated"),
-        ("([a, b]) : int = (1, 2)", "only single target (not list) can be annotated"),
-        ("a, b: int, int = 1, 2", "only single target (not tuple) can be annotated"),
-        ("{a, b} : set", "illegal target for annotation"),
-        ("a + 1 = 2", "cannot assign to expression"),
-        ("[i for i in range(2)] = 2", "cannot assign to list comprehension"),
-        ("yield a = 1", "assignment to yield expression not possible"),
-        ("a = yield b = 1", "assignment to yield expression not possible"),
-        ("a + 1 += 1", "expression is an illegal expression for augmented assignment"),
-        ("a + 1 += yield", "expression is an illegal expression for augmented assignment"),
+        ("'a' = 1", "cannot assign to literal", (1, 1), (1, 4)),
+        ("1 = 1", "cannot assign to literal", (1, 1), (1, 2)),
+        ("True = 1", "cannot assign to True", (1, 1), (1, 5)),
+        ("False = 1", "cannot assign to False", (1, 1), (1, 6)),
+        ("... = 1", "cannot assign to ellipsis", (1, 1), (1, 4)),
+        ("None = 1", "cannot assign to None", (1, 1), (1, 5)),
+        (
+            "(a, b) : int = (1, 2)",
+            "only single target (not tuple) can be annotated",
+            (1, 1),
+            (1, 7),
+        ),
+        (
+            "[a, b] : int = [1, 2]",
+            "only single target (not list) can be annotated",
+            (1, 1),
+            (1, 7),
+        ),
+        (
+            "([a, b]) : int = (1, 2)",
+            "only single target (not list) can be annotated",
+            (1, 2),
+            (1, 8),
+        ),
+        (
+            "a, b: int, int = 1, 2",
+            "only single target (not tuple) can be annotated",
+            (1, 1),
+            (1, 2),
+        ),
+        ("{a, b} : set", "illegal target for annotation", (1, 1), (1, 7)),
+        ("a + 1 = 2", "cannot assign to expression", (1, 1), (1, 6)),
+        ("[i for i in range(2)] = 2", "cannot assign to list comprehension", (1, 1), (1, 22)),
+        ("yield a = 1", "assignment to yield expression not possible", (1, 1), (1, 8)),
+        ("a = yield b = 1", "assignment to yield expression not possible", (1, 5), (1, 12)),
+        (
+            "a + 1 += 1",
+            "'expression' is an illegal expression for augmented assignment",
+            (1, 1),
+            (1, 6),
+        ),
+        (
+            "a + 1 += yield",
+            "'expression' is an illegal expression for augmented assignment",
+            (1, 1),
+            (1, 6),
+        ),
         (
             "[i for i in range(2)] += 1",
-            "list comprehension is an illegal expression for augmented assignment",
+            "'list comprehension' is an illegal expression for augmented assignment",
+            (1, 1),
+            (1, 22),
         ),
-        ("a += raise", "invalid syntax"),
+        ("a += raise", "invalid syntax", (1, 6), (1, 11)),
     ],
 )
-def test_invalid_assignments(python_parse_file, python_parse_str, tmp_path, source, message):
+def test_invalid_assignments(
+    python_parse_file, python_parse_str, tmp_path, source, message, start, end
+):
     parse_invalid_syntax(
-        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message
+        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message, start, end
     )
 
 
 @pytest.mark.parametrize(
-    "source, message",
+    "source, message, start, end",
     [
-        ("del [i for i in range(2)]", "cannot delete list comprehension"),
-        ("del a + 1", "cannot delete expression"),
+        ("del [i for i in range(2)]", "cannot delete list comprehension", (1, 5), (1, 26)),
+        ("del a + 1", "cannot delete expression", (1, 5), (1, 10)),
     ],
 )
-def test_invalid_del_statements(python_parse_file, python_parse_str, tmp_path, source, message):
+def test_invalid_del_statements(
+    python_parse_file, python_parse_str, tmp_path, source, message, start, end
+):
     parse_invalid_syntax(
-        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message
+        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message, start, end
     )
 
 
 @pytest.mark.parametrize(
-    "source, message",
+    "source, message, start, end",
     [
         (
             "(*a for a in enumerate(range(5)))",
             "iterable unpacking cannot be used in comprehension",
+            (1, 2),
+            (1, 4),
         ),
         (
             "[*a for a in enumerate(range(5))]",
             "iterable unpacking cannot be used in comprehension",
+            (1, 2),
+            (1, 4),
         ),
         (
             "{*a for a in enumerate(range(5))}",
             "iterable unpacking cannot be used in comprehension",
+            (1, 2),
+            (1, 4),
         ),
         (
             "[a, a for a in range(5)]",
             "did you forget parentheses around the comprehension target?",
+            (1, 2),
+            (1, 6),
         ),
         (
             "[a, a for a in range(5)]",
             "did you forget parentheses around the comprehension target?",
+            (1, 2),
+            (1, 6),
         ),
         (
             "[a,  for a in range(5)]",
             "did you forget parentheses around the comprehension target?",
+            (1, 2),
+            (1, 4),
         ),
         (
             "[a,  for a in range(5)]",
             "did you forget parentheses around the comprehension target?",
+            (1, 2),
+            (1, 4),
+        ),
+        (
+            "{**a for a in [{1: 2}]}",
+            "dict unpacking cannot be used in dict comprehension",
+            (1, 2),
+            (1, 4),
         ),
-        ("{**a for a in [{1: 2}]}", "dict unpacking cannot be used in dict comprehension"),
         # check cuts
-        ("(a for a in raise)", "invalid syntax"),
-        ("(a async for a in raise)", "invalid syntax"),
+        ("(a for a in raise)", "invalid syntax", (1, 13), (1, 18)),
+        ("(a async for a in raise)", "invalid syntax", (1, 19), (1, 24)),
     ],
 )
-def test_invalid_comprehension(python_parse_file, python_parse_str, tmp_path, source, message):
+def test_invalid_comprehension(
+    python_parse_file, python_parse_str, tmp_path, source, message, start, end
+):
     parse_invalid_syntax(
-        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message
+        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message, start, end
     )
 
 
 @pytest.mark.parametrize(
-    "source",
+    "source, start, end",
     [
-        "def f(a=1, b):\n\tpass",
-        "def f(a=1, /, b):\n\tpass",
-        "lambda x=1, y: x",
-        "lambda x=1, /, y: x",
+        ("def f(a=1, b):\n\tpass", (1, 12), (1, 13)),
+        ("def f(a=1, /, b):\n\tpass", (1, 15), (1, 16)),
+        ("lambda x=1, y: x", (1, 13), (1, 14)),
+        ("lambda x=1, /, y: x", (1, 16), (1, 17)),
     ],
 )
-def test_invalid_parameters(python_parse_file, python_parse_str, tmp_path, source):
+def test_invalid_parameters(python_parse_file, python_parse_str, tmp_path, source, start, end):
     parse_invalid_syntax(
         python_parse_file,
         python_parse_str,
@@ -205,20 +341,22 @@ def test_invalid_parameters(python_parse_file, python_parse_str, tmp_path, sourc
         source,
         SyntaxError,
         "non-default argument follows default argument",
+        start,
+        end,
     )
 
 
 @pytest.mark.parametrize(
-    "source",
+    "source, start, end",
     [
-        "def f(a, *):\n\tpass",
-        "def f(a, *,):\n\tpass",
-        "def f(a, *, **):\n\tpass",
-        "lambda a, *: a",
-        "lambda a, *, **:a",
+        ("def f(a, *):\n\tpass", (1, 10), (1, 11)),
+        ("def f(a, *,):\n\tpass", (1, 10), (1, 11)),
+        ("def f(a, *, **):\n\tpass", (1, 10), (1, 11)),
+        ("lambda a, *: a", (1, 12), (1, 13)),
+        ("lambda a, *, **:a", (1, 14), (1, 16)),
     ],
 )
-def test_invalid_star_etc(python_parse_file, python_parse_str, tmp_path, source):
+def test_invalid_star_etc(python_parse_file, python_parse_str, tmp_path, source, start, end):
     parse_invalid_syntax(
         python_parse_file,
         python_parse_str,
@@ -226,396 +364,519 @@ def test_invalid_star_etc(python_parse_file, python_parse_str, tmp_path, source)
         source,
         SyntaxError,
         "named arguments must follow bare *",
+        start,
+        end,
     )
 
 
 @pytest.mark.parametrize(
-    "source, message",
+    "source, message, start, end",
     [
-        ("with open(a) as {b: 1}:\n\tpass", "cannot assign to dict"),
-        ("with open(a) as {b}:\n\tpass", "cannot assign to set"),
-        ("with open(a) as 1:\n\tpass", "cannot assign to 1"),
+        ("with open(a) as {b: 1}:\n\tpass", "cannot assign to dict", (1, 17), (1, 23)),
+        ("with open(a) as {b}:\n\tpass", "cannot assign to set", (1, 17), (1, 20)),
+        ("with open(a) as 1:\n\tpass", "cannot assign to literal", (1, 17), (1, 18)),
     ],
 )
-def test_invalid_with_item(python_parse_file, python_parse_str, tmp_path, source, message):
+def test_invalid_with_item(
+    python_parse_file, python_parse_str, tmp_path, source, message, start, end
+):
     parse_invalid_syntax(
-        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message
+        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message, start, end
     )
 
 
 @pytest.mark.parametrize(
-    "source, message",
+    "source, message, start, end",
     [
-        ("for {a} in [[1]]:\n\tpass", "cannot assign to comparison"),
-        ("async for (a := i)", "cannot assign to named expression"),
+        ("for {a} in [[1]]:\n\tpass", "cannot assign to set display", (1, 5), (1, 8)),
+        ("async for (a := i)", "cannot assign to named expression", (1, 12), (1, 18)),
     ],
 )
-def test_invalid_for_target(python_parse_file, python_parse_str, tmp_path, source, message):
+def test_invalid_for_target(
+    python_parse_file, python_parse_str, tmp_path, source, message, start, end
+):
     parse_invalid_syntax(
-        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message
+        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message, start, end
     )
 
 
 @pytest.mark.parametrize(
-    "source, message",
+    "source, message, start, end",
     [
-        ("a = (1+1 := 2)", "cannot use assignment expressions with expression"),
-        ("a := raise", "invalid syntax"),
+        ("a = (1+1 := 2)", "cannot use assignment expressions with expression", (1, 6), (1, 9)),
+        ("a := raise", "invalid syntax", (1, 3), (1, 5)),
     ],
 )
-def test_named_expression(python_parse_file, python_parse_str, tmp_path, source, message):
+def test_named_expression(
+    python_parse_file, python_parse_str, tmp_path, source, message, start, end
+):
     parse_invalid_syntax(
-        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message
+        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message, start, end
     )
 
 
 @pytest.mark.parametrize(
-    "source, message",
+    "source, message, start, end",
     [
-        ("a = (*b)", "cannot use starred expression here"),
-        ("a = (**b)", "cannot use double starred expression here"),
+        ("a = (*b)", "cannot use starred expression here", (1, 6), (1, 8)),
+        ("a = (**b)", "cannot use double starred expression here", (1, 6), (1, 8)),
     ],
 )
-def test_invalid_group(python_parse_file, python_parse_str, tmp_path, source, message):
+def test_invalid_group(python_parse_file, python_parse_str, tmp_path, source, message, start, end):
     parse_invalid_syntax(
-        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message
+        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message, start, end
     )
 
 
 @pytest.mark.parametrize(
-    "source, message",
+    "source, message, start, end",
     [
-        ("from a import b,", "trailing comma not allowed without surrounding parentheses"),
-        ("from a import b, and 3", "invalid syntax"),
-        ("from a import raise", "invalid syntax"),
+        (
+            "from a import b,",
+            "trailing comma not allowed without surrounding parentheses",
+            (1, 17),
+            (1, 17),
+        ),
+        ("from a import b, and 3", "invalid syntax", (1, 18), (1, 21)),
+        ("from a import raise", "invalid syntax", (1, 15), (1, 20)),
     ],
 )
 def test_invalid_import_from_as_names(
-    python_parse_file, python_parse_str, tmp_path, source, message
+    python_parse_file, python_parse_str, tmp_path, source, message, start, end
 ):
     parse_invalid_syntax(
-        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message
+        python_parse_file, python_parse_str, tmp_path, source, SyntaxError, message, start, end
     )
 
 
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, end",
     [
         (
             "with open(a) as f, b as d:\npass",
             IndentationError,
             "expected an indented block after 'with' statement on line 1",
+            (2, 1),
+            (2, 5),
         ),
         (
             "\nasync with (open(a) as f, b as d):\npass",
             IndentationError,
             "expected an indented block after 'with' statement on line 2",
+            (3, 1),
+            (3, 5),
+        ),
+        ("with open(a) as f, b as d\npass", SyntaxError, "expected ':'", (1, 26), (1, 26)),
+        (
+            "\nasync with (open(a) as f, b as d)\npass",
+            SyntaxError,
+            "expected ':'",
+            (2, 34),
+            (2, 34),
         ),
-        ("with open(a) as f, b as d\npass", SyntaxError, "expected ':'"),
-        ("\nasync with (open(a) as f, b as d)\npass", SyntaxError, "expected ':'"),
     ],
 )
 def test_invalid_with_stmt(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
+    )
 
 
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, end",
     [
         (
             "try:\npass",
             IndentationError,
             "expected an indented block after 'try' statement on line 1",
+            (2, 1),
+            (2, 5),
+        ),
+        ("try\n\tpass", SyntaxError, "expected ':'", (1, 4), (1, 4)),
+        (
+            "try:\n\tpass\na = 1",
+            SyntaxError,
+            "expected 'except' or 'finally' block",
+            (3, 1),
+            (3, 2),
         ),
-        ("try\n\tpass", SyntaxError, "expected ':'"),
-        ("try:\n\tpass\na = 1", SyntaxError, "expected 'except' or 'finally' block"),
     ],
 )
 def test_invalid_try_stmt(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
+    )
 
 
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, end",
     [
         (
             "try:\n\tpass\nexcept:\npass",
-            IndentationError,
+            # NOTE: there are 2 CPython bugs here, the exception type and the missing quote
+            # in the message
+            SyntaxError,  # IndentationError,
             "expected an indented block after 'except' statement on line 3",
+            (4, 1),
+            (4, 5),
         ),
         (
             "try:\n\tpass\nexcept Exception:\npass",
             IndentationError,
             "expected an indented block after 'except' statement on line 3",
+            (4, 1),
+            (4, 5),
         ),
         (
             "try:\n\tpass\nexcept Exception as e:\npass",
             IndentationError,
             "expected an indented block after 'except' statement on line 3",
+            (4, 1),
+            (4, 5),
         ),
         (
             "try:\n\tpass\nexcept ValueError, IndexError as e:",
             SyntaxError,
-            "exception group must be parenthesized",
+            "multiple exception types must be parenthesized",
+            (3, 8),
+            (3, 35),
         ),
         (
             "try:\n\tpass\nexcept ValueError, IndexError:",
             SyntaxError,
-            "exception group must be parenthesized",
+            "multiple exception types must be parenthesized",
+            (3, 8),
+            (3, 30),
         ),
         (
             "try:\n\tpass\nexcept ValueError, IndexError,:",
             SyntaxError,
-            "exception group must be parenthesized",
+            "multiple exception types must be parenthesized",
+            (3, 8),
+            (3, 31),
         ),
         (
             "try:\n\tpass\nexcept ValueError, IndexError, a=1:",
             SyntaxError,
             "invalid syntax",
+            (3, 18),
+            (3, 19),
         ),
-        ("try:\n\tpass\nexcept Exception\npass", SyntaxError, "expected ':'"),
-        ("try:\n\tpass\nexcept Exception as e\npass", SyntaxError, "expected ':'"),
-        ("try:\n\tpass\nexcept\npass", SyntaxError, "expected ':'"),
+        ("try:\n\tpass\nexcept Exception\npass", SyntaxError, "expected ':'", (3, 17), (3, 17)),
+        (
+            "try:\n\tpass\nexcept Exception as e\npass",
+            SyntaxError,
+            "expected ':'",
+            (3, 22),
+            (3, 22),
+        ),
+        ("try:\n\tpass\nexcept\npass", SyntaxError, "expected ':'", (3, 7), (3, 7)),
     ],
 )
 def test_invalid_except_stmt(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
+    )
 
 
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, stop",
     [
         (
             "try:\n\tpass\nfinally:\npass",
             IndentationError,
             "expected an indented block after 'finally' statement on line 3",
+            (4, 1),
+            (4, 5),
         ),
         (
             "try:\n\tpass\nexcept Exception:\n\tpass\nfinally:\npass",
             IndentationError,
             "expected an indented block after 'finally' statement on line 5",
+            (6, 1),
+            (6, 5),
         ),
     ],
 )
 def test_invalid_finally_stmt(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, stop
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, stop
+    )
 
 
 @pytest.mark.skipif(sys.version_info < (3, 10), reason="Valid only in Python 3.10+")
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, end",
     [
-        ("match a\n\tpass", SyntaxError, "expected ':'"),
+        ("match a\n\tpass", SyntaxError, "expected ':'", (1, 8), (1, 8)),
         (
             "match a:\npass",
             IndentationError,
             "expected an indented block after 'match' statement on line 1",
+            (2, 1),
+            (2, 5),
         ),
     ],
 )
 def test_invalid_match_stmt(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
+    )
 
 
 @pytest.mark.skipif(sys.version_info < (3, 10), reason="Valid only in Python 3.10+")
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, end",
     [
-        ("match a:\n\tcase 1\n\t\tpass", SyntaxError, "expected ':'"),
+        ("match a:\n\tcase 1\n\t\tpass", SyntaxError, "expected ':'", (2, 8), (2, 8)),
         (
             "match a:\n\tcase 1:\n\tpass",
             IndentationError,
             "expected an indented block after 'case' statement on line 2",
+            (3, 2),
+            (3, 6),
         ),
     ],
 )
 def test_invalid_case_stmt(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
+    )
 
 
 @pytest.mark.skipif(sys.version_info < (3, 10), reason="Valid only in Python 3.10+")
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, end",
     [
         # As pattern
-        ("match a:\n\tcase 1 as _:\n\t\tpass", SyntaxError, "cannot use '_' as a target"),
+        (
+            "match a:\n\tcase 1 as _:\n\t\tpass",
+            SyntaxError,
+            "cannot use '_' as a target",
+            (2, 12),
+            (2, 13),
+        ),
         (
             "match a:\n\tcase 1 as 1+1:\n\t\tpass",
             SyntaxError,
             "invalid pattern target",
+            (2, 12),
+            (2, 15),
         ),
         # Class pattern
         (
             "match a:\n\tcase Foo(z=1, y=2, x):\n\t\tpass",
             SyntaxError,
             "positional patterns follow keyword patterns",
+            (2, 21),
+            (2, 22),
         ),
         (
             "match a:\n\tcase Foo(a, z=1, y=2, x):\n\t\tpass",
             SyntaxError,
             "positional patterns follow keyword patterns",
+            (2, 24),
+            (2, 25),
         ),
         (
             "match a:\n\tcase Foo(z=1, x, y=2):\n\t\tpass",
             SyntaxError,
             "positional patterns follow keyword patterns",
+            (2, 16),
+            (2, 17),
         ),
         (
             "match a:\n\tcase Foo(a=b, c, d=e, f, g=h, i, j=k, ...):\n\t\tpass",
             SyntaxError,
             "positional patterns follow keyword patterns",
+            (2, 16),
+            (2, 17),
         ),
         (
             "match x:\n\tcase -1j + 1j:\n\t\tpass",
             SyntaxError,
             "real number required in complex literal",
+            (2, 8),
+            (2, 10),
         ),
         (
             "match x:\n\tcase -1 + 1:\n\t\tpass",
             SyntaxError,
             "imaginary number required in complex literal",
-        )
+            (2, 12),
+            (2, 13),
+        ),
     ],
 )
 def test_invalid_case_pattern(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
+    )
 
 
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, end",
     [
-        ("if a\n\tpass", SyntaxError, "expected ':'"),
+        ("if a\n\tpass", SyntaxError, "expected ':'", (1, 5), (1, 5)),
         (
             "if a:\npass",
             IndentationError,
             "expected an indented block after 'if' statement on line 1",
+            (2, 1),
+            (2, 5),
         ),
     ],
 )
 def test_invalid_if_stmt(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
+    )
 
 
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, end",
     [
-        ("if a:\n\tpass\nelif a\n\tpass", SyntaxError, "expected ':'"),
+        ("if a:\n\tpass\nelif a\n\tpass", SyntaxError, "expected ':'", (3, 7), (3, 7)),
         (
             "if a:\n\tpass\nelif b:\npass",
             IndentationError,
             "expected an indented block after 'elif' statement on line 3",
+            (4, 1),
+            (4, 5),
         ),
     ],
 )
 def test_invalid_elif_stmt(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
+    )
 
 
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, end",
     [
-        ("if a:\n\tpass\nelse\n\tpass", SyntaxError, "expected ':'"),
+        ("if a:\n\tpass\nelse\n\tpass", SyntaxError, "expected ':'", (3, 5), (3, 5)),
         (
             "if a:\n\tpass\nelse:\npass",
             IndentationError,
             "expected an indented block after 'else' statement on line 3",
+            (4, 1),
+            (4, 5),
         ),
     ],
 )
 def test_invalid_else_stmt(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
+    )
 
 
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, end",
     [
-        ("while a\n\tpass", SyntaxError, "expected ':'"),
+        ("while a\n\tpass", SyntaxError, "expected ':'", (1, 8), (1, 8)),
         (
             "while a:\npass",
             IndentationError,
             "expected an indented block after 'while' statement on line 1",
+            (2, 1),
+            (2, 5),
         ),
     ],
 )
 def test_invalid_while_stmt(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
+    )
 
 
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, end",
     [
         (
             "for a in range(10):\npass",
             IndentationError,
             "expected an indented block after 'for' statement on line 1",
+            (2, 1),
+            (2, 5),
         ),
         (
             "async for a in range(10):\npass",
             IndentationError,
             "expected an indented block after 'for' statement on line 1",
+            (2, 1),
+            (2, 5),
         ),
-        (
-            "for a in raise:\npass",
-            SyntaxError,
-            "invalid syntax",
-        ),
+        ("for a in raise:\npass", SyntaxError, "invalid syntax", (1, 10), (1, 15)),
         (
             "async for a in raise:\npass",
             SyntaxError,
             "invalid syntax",
+            (1, 16),
+            (1, 21),
         ),
     ],
 )
 def test_invalid_for_stmt(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
+    )
 
 
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, end",
     [
         (
             "def f():\npass",
             IndentationError,
             "expected an indented block after function definition on line 1",
+            (2, 1),
+            (2, 5),
         ),
         (
             "async def f():\npass",
             IndentationError,
             "expected an indented block after function definition on line 1",
+            (2, 1),
+            (2, 5),
         ),
         (
             "def f(a,):\npass",
             IndentationError,
             "expected an indented block after function definition on line 1",
+            (2, 1),
+            (2, 5),
         ),
         (
             "def f() -> None:\npass",
             IndentationError,
             "expected an indented block after function definition on line 1",
+            (2, 1),
+            (2, 5),
         ),
         # (
         #     "def f():\n# type: () -> int\n# type: () -> str\n\tpass",
@@ -625,46 +886,87 @@ def test_invalid_for_stmt(
     ],
 )
 def test_invalid_def_stmt(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
+    )
 
 
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, end",
     [
         (
             "class A:\npass",
             IndentationError,
             "expected an indented block after class definition on line 1",
+            (2, 1),
+            (2, 5),
         ),
         (
             "class f(object):\npass",
             IndentationError,
             "expected an indented block after class definition on line 1",
+            (2, 1),
+            (2, 5),
         ),
     ],
 )
 def test_invalid_class_stmt(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
+    )
 
 
 @pytest.mark.parametrize(
-    "source, exception, message",
+    "source, exception, message, start, end",
     [
-        ("{a: 1, b}", SyntaxError, "':' expected after dictionary key"),
-        ("{a: 1, c: 2, b}", SyntaxError, "':' expected after dictionary key"),
-        ("{a: 1, b:}", SyntaxError, "expression expected after dictionary key and ':'"),
-        ("{c: 1, a: *b}", SyntaxError, "cannot use a starred expression in a dictionary value"),
-        ("{b:}", SyntaxError, "expression expected after dictionary key and ':'"),
-        ("{b:, c}", SyntaxError, "expression expected after dictionary key and ':'"),
-        ("{a: *b}", SyntaxError, "cannot use a starred expression in a dictionary value"),
-        ("{**c, a: *b}", SyntaxError, "cannot use a starred expression in a dictionary value"),
+        # NOTE CPython reports 0 as the end offset which looks odd to me
+        ("{a: 1, b}", SyntaxError, "':' expected after dictionary key", (1, 8), (1, None)),
+        ("{a: 1, c: 2, b}", SyntaxError, "':' expected after dictionary key", (1, 14), (1, None)),
+        (
+            "{a: 1, b:}",
+            SyntaxError,
+            "expression expected after dictionary key and ':'",
+            (1, 9),
+            (1, 10),
+        ),
+        (
+            "{c: 1, a: *b}",
+            SyntaxError,
+            "cannot use a starred expression in a dictionary value",
+            (1, 11),
+            (1, 13),
+        ),
+        ("{b:}", SyntaxError, "expression expected after dictionary key and ':'", (1, 3), (1, 4)),
+        (
+            "{b:, c}",
+            SyntaxError,
+            "expression expected after dictionary key and ':'",
+            (1, 3),
+            (1, 4),
+        ),
+        (
+            "{a: *b}",
+            SyntaxError,
+            "cannot use a starred expression in a dictionary value",
+            (1, 5),
+            (1, 7),
+        ),
+        (
+            "{**c, a: *b}",
+            SyntaxError,
+            "cannot use a starred expression in a dictionary value",
+            (1, 10),
+            (1, 12),
+        ),
     ],
 )
 def test_invalid_dict_key_value(
-    python_parse_file, python_parse_str, tmp_path, source, exception, message
+    python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
 ):
-    parse_invalid_syntax(python_parse_file, python_parse_str, tmp_path, source, exception, message)
+    parse_invalid_syntax(
+        python_parse_file, python_parse_str, tmp_path, source, exception, message, start, end
+    )