Repository: pylint-dev/astroid

Issue:
Decorator.toline is off by 1
### Steps to reproduce

I came across this inconsistency while debugging why pylint reports `missing-docstring` on the wrong line for the `g2` function in the example. As it turns out, the `toline` of the decorator seems to point to `b=3,` instead of `)`.

```python
import ast
import astroid

source = """\
@f(a=2,
   b=3,
)
def g2():
    pass
"""

[f] = ast.parse(source).body
[deco] = f.decorator_list
print("ast", deco.lineno, deco.end_lineno)

[f] = astroid.parse(source).body
[deco] = f.decorators.nodes
print("astroid", deco.fromlineno, deco.tolineno)
```

### Current behavior

```
ast 1 3
astroid 1 2
```

### Expected behavior

```
ast 1 3
astroid 1 3
```

### `python -c "from astroid import __pkginfo__; print(__pkginfo__.version)"` output

2.9.3


Discussion:
Actually, it seems a decorator is a red herring here, because I get the same off by one issue simply parsing a call

```python
source = """\
f(a=2,
   b=3,
)
"""

[call] = ast.parse(source).body
print("ast", call.lineno, call.end_lineno)

[call] = astroid.parse(source).body
print("astroid", call.fromlineno, call.tolineno)
```

which outputs

```
ast 1 3
astroid 1 2
```
Okay, this seems to be caused by the implementation of `NodeNG.tolineno` which uses the last line of the *child* to approximate the last line of the parent:

https://github.com/PyCQA/astroid/blob/03efcc3f86b88bab3080fe69119ee4c69e4afd0a/astroid/nodes/node_ng.py#L437-L446

Once possible fix is to override `tolineno` in `Call`. Wdyt?
> this seems to be caused by the implementation of NodeNG.tolineno which uses the last line of the child to approximate the last line of the parent:

Naive question, would it be possible to use the last line of the node instead, directly in NodeNG ?
Yeah, I think that should work with a caveat that the `ast` module only reports end line/column since Python 3.8. I'll draft a PR.
@superbobry I was looking at `tolineno` recently. I was wondering if it would make sense to add a check for >= 3.8 and then just use the `end_lineno` attribute that was added recently. No need to reinvent the wheel on those versions.

Perhaps that's a bit out of the scope of the PR you were going to draft, but it might help!

Patch:
diff --git a/astroid/nodes/node_ng.py b/astroid/nodes/node_ng.py
--- a/astroid/nodes/node_ng.py
+++ b/astroid/nodes/node_ng.py
@@ -438,6 +438,8 @@ def fromlineno(self) -> Optional[int]:
     @decorators.cachedproperty
     def tolineno(self) -> Optional[int]:
         """The last line that this node appears on in the source code."""
+        if self.end_lineno is not None:
+            return self.end_lineno
         if not self._astroid_fields:
             # can't have children
             last_child = None
diff --git a/astroid/rebuilder.py b/astroid/rebuilder.py
--- a/astroid/rebuilder.py
+++ b/astroid/rebuilder.py
@@ -2125,11 +2125,21 @@ def visit_starred(self, node: "ast.Starred", parent: NodeNG) -> nodes.Starred:
     def visit_tryexcept(self, node: "ast.Try", parent: NodeNG) -> nodes.TryExcept:
         """visit a TryExcept node by returning a fresh instance of it"""
         if sys.version_info >= (3, 8):
+            # TryExcept excludes the 'finally' but that will be included in the
+            # end_lineno from 'node'. Therefore, we check all non 'finally'
+            # children to find the correct end_lineno and column.
+            end_lineno = node.end_lineno
+            end_col_offset = node.end_col_offset
+            all_children: List["ast.AST"] = [*node.body, *node.handlers, *node.orelse]
+            for child in reversed(all_children):
+                end_lineno = child.end_lineno
+                end_col_offset = child.end_col_offset
+                break
             newnode = nodes.TryExcept(
                 lineno=node.lineno,
                 col_offset=node.col_offset,
-                end_lineno=node.end_lineno,
-                end_col_offset=node.end_col_offset,
+                end_lineno=end_lineno,
+                end_col_offset=end_col_offset,
                 parent=parent,
             )
         else:


Test patch:
diff --git a/tests/unittest_builder.py b/tests/unittest_builder.py
--- a/tests/unittest_builder.py
+++ b/tests/unittest_builder.py
@@ -71,11 +71,14 @@ def test_callfunc_lineno(self) -> None:
         strarg = callfunc.args[0]
         self.assertIsInstance(strarg, nodes.Const)
         if hasattr(sys, "pypy_version_info"):
-            lineno = 4
+            self.assertEqual(strarg.fromlineno, 4)
+            self.assertEqual(strarg.tolineno, 4)
         else:
-            lineno = 5 if not PY38_PLUS else 4
-        self.assertEqual(strarg.fromlineno, lineno)
-        self.assertEqual(strarg.tolineno, lineno)
+            if not PY38_PLUS:
+                self.assertEqual(strarg.fromlineno, 5)
+            else:
+                self.assertEqual(strarg.fromlineno, 4)
+            self.assertEqual(strarg.tolineno, 5)
         namearg = callfunc.args[1]
         self.assertIsInstance(namearg, nodes.Name)
         self.assertEqual(namearg.fromlineno, 5)
diff --git a/tests/unittest_nodes_lineno.py b/tests/unittest_nodes_lineno.py
--- a/tests/unittest_nodes_lineno.py
+++ b/tests/unittest_nodes_lineno.py
@@ -784,7 +784,7 @@ def test_end_lineno_try() -> None:
         assert (t3.lineno, t3.col_offset) == (10, 0)
         assert (t3.end_lineno, t3.end_col_offset) == (17, 8)
         assert (t3.body[0].lineno, t3.body[0].col_offset) == (10, 0)
-        assert (t3.body[0].end_lineno, t3.body[0].end_col_offset) == (17, 8)
+        assert (t3.body[0].end_lineno, t3.body[0].end_col_offset) == (15, 8)
         assert (t3.finalbody[0].lineno, t3.finalbody[0].col_offset) == (17, 4)
         assert (t3.finalbody[0].end_lineno, t3.finalbody[0].end_col_offset) == (17, 8)
 
diff --git a/tests/unittest_scoped_nodes.py b/tests/unittest_scoped_nodes.py
--- a/tests/unittest_scoped_nodes.py
+++ b/tests/unittest_scoped_nodes.py
@@ -1092,15 +1092,19 @@ def g1(x):
                 print(x)
 
             @f(a=2,
-               b=3)
+               b=3,
+            )
             def g2():
                 pass
         """
         astroid = builder.parse(data)
         self.assertEqual(astroid["g1"].fromlineno, 4)
         self.assertEqual(astroid["g1"].tolineno, 5)
-        self.assertEqual(astroid["g2"].fromlineno, 9)
-        self.assertEqual(astroid["g2"].tolineno, 10)
+        if not PY38_PLUS:
+            self.assertEqual(astroid["g2"].fromlineno, 9)
+        else:
+            self.assertEqual(astroid["g2"].fromlineno, 10)
+        self.assertEqual(astroid["g2"].tolineno, 11)
 
     def test_metaclass_error(self) -> None:
         astroid = builder.parse(