Repository: felix-hilden/sphinx-codeautolink

Issue:
Attribute access after NamedExpr (walrus) leads to parsing error
With 0.17.0, I am getting the following `AttributeError` when using the extension with the Python docs (added to the build as in https://github.com/python/cpython/compare/main...cmarqu:cpython:feat/add-sphinx-codeautolink):

```
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 473, in visit_Attribute
    inner.components.append(Component.from_ast(node))
    ^^^^^^^^^^^^^^^^
AttributeError: 'Assignment' object has no attribute 'components' (in document `/home/colin/tools/cpython/Doc/whatsnew/3.8.rst`)
```

<details><summary>Full Traceback</summary>
<p>
<pre>
Traceback (most recent call last):
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/events.py", line 404, in emit
    results.append(listener.handler(self.app, *args))
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/extension/__init__.py", line 46, in wrapper
    return func(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/extension/__init__.py", line 150, in parse_blocks
    doctree.walkabout(visitor)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/nodes.py", line 186, in walkabout
    if child.walkabout(visitor):
       ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/nodes.py", line 186, in walkabout
    if child.walkabout(visitor):
       ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/nodes.py", line 186, in walkabout
    if child.walkabout(visitor):
       ^^^^^^^^^^^^^^^^^^^^^^^^
  [Previous line repeated 2 more times]
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/nodes.py", line 178, in walkabout
    visitor.dispatch_visit(self)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/nodes.py", line 1992, in dispatch_visit
    return method(node)
           ^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/extension/block.py", line 197, in visit_literal_block
    return self.parse_source(node, node.get("language", self.highlight_lang))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/extension/block.py", line 250, in parse_source
    names = parse_names(modified_source, node)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 24, in parse_names
    visitor.visit(tree)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 292, in visit
    return super().visit(node)
           ^^^^^^^^^^^^^^^^^^^
  File "/home/colin/.local/share/uv/python/cpython-3.12.5-linux-x86_64-gnu/lib/python3.12/ast.py", line 407, in visit
    return visitor(node)
           ^^^^^^^^^^^^^
  File "/home/colin/.local/share/uv/python/cpython-3.12.5-linux-x86_64-gnu/lib/python3.12/ast.py", line 415, in generic_visit
    self.visit(item)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 292, in visit
    return super().visit(node)
           ^^^^^^^^^^^^^^^^^^^
  File "/home/colin/.local/share/uv/python/cpython-3.12.5-linux-x86_64-gnu/lib/python3.12/ast.py", line 407, in visit
    return visitor(node)
           ^^^^^^^^^^^^^
  File "/home/colin/.local/share/uv/python/cpython-3.12.5-linux-x86_64-gnu/lib/python3.12/ast.py", line 417, in generic_visit
    self.visit(value)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 292, in visit
    return super().visit(node)
           ^^^^^^^^^^^^^^^^^^^
  File "/home/colin/.local/share/uv/python/cpython-3.12.5-linux-x86_64-gnu/lib/python3.12/ast.py", line 407, in visit
    return visitor(node)
           ^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 690, in visit_DictComp
    self.visit_generic_comp([node.key, node.value], node.generators)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 712, in visit_generic_comp
    inner.visit(value)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 289, in visit
    return super().visit(node)
           ^^^^^^^^^^^^^^^^^^^
  File "/home/colin/.local/share/uv/python/cpython-3.12.5-linux-x86_64-gnu/lib/python3.12/ast.py", line 407, in visit
    return visitor(node)
           ^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 227, in wrapper
    result = func(self, *args, **kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 479, in visit_Call
    inner: PendingAccess | None = self.visit(node.func)
                                  ^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 289, in visit
    return super().visit(node)
           ^^^^^^^^^^^^^^^^^^^
  File "/home/colin/.local/share/uv/python/cpython-3.12.5-linux-x86_64-gnu/lib/python3.12/ast.py", line 407, in visit
    return visitor(node)
           ^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 227, in wrapper
    result = func(self, *args, **kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 473, in visit_Attribute
    inner.components.append(Component.from_ast(node))
    ^^^^^^^^^^^^^^^^
AttributeError: 'Assignment' object has no attribute 'components' (in document `/home/colin/tools/cpython/Doc/whatsnew/3.8.rst`)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/util/parallel.py", line 77, in _process
    ret = func(arg)
          ^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/builders/__init__.py", line 586, in read_process
    self.read_doc(docname, _cache=False)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/builders/__init__.py", line 630, in read_doc
    publisher.publish()
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/core.py", line 236, in publish
    self.apply_transforms()
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/core.py", line 216, in apply_transforms
    self.document.transformer.apply_transforms()
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/transforms/__init__.py", line 94, in apply_transforms
    super().apply_transforms()  # type: ignore[misc]
    ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/transforms/__init__.py", line 182, in apply_transforms
    transform.apply(**kwargs)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/transforms/__init__.py", line 433, in apply
    self.app.emit('doctree-read', self.document)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/application.py", line 819, in emit
    return self.events.emit(event, *args, allowed_exceptions=allowed_exceptions)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/events.py", line 415, in emit
    raise ExtensionError(
sphinx.errors.ExtensionError: Handler <bound method SphinxCodeAutoLink.parse_blocks of <sphinx_codeautolink.extension.SphinxCodeAutoLink object at 0x14c479eef6b0>> for event 'doctree-read' threw an exception (exception: 'Assignment' object has no attribute 'components' (in document `/home/colin/tools/cpython/Doc/whatsnew/3.8.rst`))
</pre>
</p>
</details>

Discussion:
@felix-hilden: Thanks 👌 I just pushed [`debug-parse`](https://github.com/felix-hilden/sphinx-codeautolink/tree/debug-parse), could you build with that to see what exactly this fails on?@cmarqu: Getting this:

```python
/home/colin/tools/cpython/Doc/whatsnew/3.7.rst:2255: WARNING: Generator expression must be parenthesized (<unknown>, line 1) in document 'whatsnew/3.7'
Parsed source in `python3` block:
block source: f(1 for x in [1],)
              
              class C(1 for x in [1]):
                  pass [codeautolink.parse_block]
/home/colin/tools/cpython/Doc/whatsnew/3.8.rst:164: WARNING: expected ':' (<unknown>, line 1) in document 'whatsnew/3.8'
Parsed source in `python3` block:
block source: def quantiles(dist, /, *, n=4, method='exclusive')
                  ... [codeautolink.parse_block]
/home/colin/tools/cpython/Doc/whatsnew/3.8.rst:180: WARNING: expected an indented block after function definition on line 3 (<unknown>, line 4) in document 'whatsnew/3.8'
Parsed source in `python3` block:
block source: class Counter(dict):
              
                  def __init__(self, iterable=None, /, **kwds):
                      # Note "iterable" is a possible keyword argument [codeautolink.parse_block]

Traceback (most recent call last):
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/extension/block.py", line 250, in parse_source
    names = parse_names(modified_source, node)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 24, in parse_names
    visitor.visit(tree)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 292, in visit
    return super().visit(node)
           ^^^^^^^^^^^^^^^^^^^
  File "/home/colin/.local/share/uv/python/cpython-3.12.5-linux-x86_64-gnu/lib/python3.12/ast.py", line 407, in visit
    return visitor(node)
           ^^^^^^^^^^^^^
  File "/home/colin/.local/share/uv/python/cpython-3.12.5-linux-x86_64-gnu/lib/python3.12/ast.py", line 415, in generic_visit
    self.visit(item)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 292, in visit
    return super().visit(node)
           ^^^^^^^^^^^^^^^^^^^
  File "/home/colin/.local/share/uv/python/cpython-3.12.5-linux-x86_64-gnu/lib/python3.12/ast.py", line 407, in visit
    return visitor(node)
           ^^^^^^^^^^^^^
  File "/home/colin/.local/share/uv/python/cpython-3.12.5-linux-x86_64-gnu/lib/python3.12/ast.py", line 417, in generic_visit
    self.visit(value)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 292, in visit
    return super().visit(node)
           ^^^^^^^^^^^^^^^^^^^
  File "/home/colin/.local/share/uv/python/cpython-3.12.5-linux-x86_64-gnu/lib/python3.12/ast.py", line 407, in visit
    return visitor(node)
           ^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 690, in visit_DictComp
    self.visit_generic_comp([node.key, node.value], node.generators)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 712, in visit_generic_comp
    inner.visit(value)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 289, in visit
    return super().visit(node)
           ^^^^^^^^^^^^^^^^^^^
  File "/home/colin/.local/share/uv/python/cpython-3.12.5-linux-x86_64-gnu/lib/python3.12/ast.py", line 407, in visit
    return visitor(node)
           ^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 227, in wrapper
    result = func(self, *args, **kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 479, in visit_Call
    inner: PendingAccess | None = self.visit(node.func)
                                  ^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 289, in visit
    return super().visit(node)
           ^^^^^^^^^^^^^^^^^^^
  File "/home/colin/.local/share/uv/python/cpython-3.12.5-linux-x86_64-gnu/lib/python3.12/ast.py", line 407, in visit
    return visitor(node)
           ^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 227, in wrapper
    result = func(self, *args, **kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/parse.py", line 473, in visit_Attribute
    inner.components.append(Component.from_ast(node))
    ^^^^^^^^^^^^^^^^
AttributeError: 'Assignment' object has no attribute 'components'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/events.py", line 404, in emit
    results.append(listener.handler(self.app, *args))
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/extension/__init__.py", line 46, in wrapper
    return func(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/extension/__init__.py", line 150, in parse_blocks
    doctree.walkabout(visitor)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/nodes.py", line 186, in walkabout
    if child.walkabout(visitor):
       ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/nodes.py", line 186, in walkabout
    if child.walkabout(visitor):
       ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/nodes.py", line 186, in walkabout
    if child.walkabout(visitor):
       ^^^^^^^^^^^^^^^^^^^^^^^^
  [Previous line repeated 2 more times]
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/nodes.py", line 178, in walkabout
    visitor.dispatch_visit(self)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/nodes.py", line 1992, in dispatch_visit
    return method(node)
           ^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/extension/block.py", line 197, in visit_literal_block
    return self.parse_source(node, node.get("language", self.highlight_lang))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx_codeautolink/extension/block.py", line 266, in parse_source
    raise type(e)(msg) from e
AttributeError: 'Assignment' object has no attribute 'components' in document 'whatsnew/3.8'
Parsed source in `pycon` block:
block source: >>> names = ['Martin von Löwis', 'Łukasz Langa', 'Walter Dörwald']
              >>> {(n := normalize('NFC', name)).casefold() : n for name in names}
              {'martin von löwis': 'Martin von Löwis',
               'łukasz langa': 'Łukasz Langa',
               'walter dörwald': 'Walter Dörwald'} (in document `/home/colin/tools/cpython/Doc/whatsnew/3.8.rst`)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/cmd/build.py", line 514, in build_main
    app.build(args.force_all, args.filenames)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/application.py", line 377, in build
    self.builder.build_all()
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/builders/__init__.py", line 301, in build_all
    self.build(None, summary=__('all source files'), method='all')
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/builders/__init__.py", line 385, in build
    updated_docnames = set(self.read())
                           ^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/builders/__init__.py", line 502, in read
    self._read_serial(docnames)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/builders/__init__.py", line 567, in _read_serial
    self.read_doc(docname)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/builders/__init__.py", line 630, in read_doc
    publisher.publish()
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/core.py", line 236, in publish
    self.apply_transforms()
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/core.py", line 216, in apply_transforms
    self.document.transformer.apply_transforms()
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/transforms/__init__.py", line 94, in apply_transforms
    super().apply_transforms()  # type: ignore[misc]
    ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/docutils/transforms/__init__.py", line 182, in apply_transforms
    transform.apply(**kwargs)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/transforms/__init__.py", line 433, in apply
    self.app.emit('doctree-read', self.document)
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/application.py", line 819, in emit
    return self.events.emit(event, *args, allowed_exceptions=allowed_exceptions)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/colin/tools/cpython/Doc/venv/lib/python3.12/site-packages/sphinx/events.py", line 415, in emit
    raise ExtensionError(
sphinx.errors.ExtensionError: Handler <bound method SphinxCodeAutoLink.parse_blocks of <sphinx_codeautolink.extension.SphinxCodeAutoLink object at 0x15024338a4e0>> for event 'doctree-read' threw an exception (exception: 'Assignment' object has no attribute 'components' in document 'whatsnew/3.8'
Parsed source in `pycon` block:
block source: >>> names = ['Martin von Löwis', 'Łukasz Langa', 'Walter Dörwald']
              >>> {(n := normalize('NFC', name)).casefold() : n for name in names}
              {'martin von löwis': 'Martin von Löwis',
               'łukasz langa': 'Łukasz Langa',
               'walter dörwald': 'Walter Dörwald'} (in document `/home/colin/tools/cpython/Doc/whatsnew/3.8.rst`))

Extension error (sphinx_codeautolink.extension):
Handler <bound method SphinxCodeAutoLink.parse_blocks of <sphinx_codeautolink.extension.SphinxCodeAutoLink object at 0x15024338a4e0>> for event 'doctree-read' threw an exception (exception: 'Assignment' object has no attribute 'components' in document 'whatsnew/3.8'
Parsed source in `pycon` block:
block source: >>> names = ['Martin von Löwis', 'Łukasz Langa', 'Walter Dörwald']
              >>> {(n := normalize('NFC', name)).casefold() : n for name in names}
              {'martin von löwis': 'Martin von Löwis',
               'łukasz langa': 'Łukasz Langa',
               'walter dörwald': 'Walter Dörwald'} (in document `/home/colin/tools/cpython/Doc/whatsnew/3.8.rst`))
gmake: *** [Makefile:56: build] Error 2
```@felix-hilden: Thanks! I'll list the cases here:

- whatsnew/3.7.rst: Genexpr without parenthesis is a syntax error and the block demonstrates it
- whatsnew/3.8.rst: quantiles def missing a colon
- whatsnew/3.8.rst: Counter init missing a body
- whatsnew/3.8.rst: normalize walrus confirmed

I think the first one should be ignored and the following two fixed in the doc (or ignored) 👌 I'll see what the walrus thing is about@cmarqu: Re ignoring, I got the impression from the thread on dicsuss.python.org that it would be preferred if the warnings could be disabled upfront by config variables (like you already have) rather than retroactively by having to use `suppress_warnings`.@felix-hilden: All our current toggles are to turn *on* optional stricter warnings, which are not enabled by default due to being noisy. It would essentially be the difference between:

```python
suppress_warnings = ["codeautolink.parse_block"]
# and
codeautolink_warn_on_parse_block_fail = False
```

The former is familiar to Sphinx users, especially if suppressed warnings exist already. Is there a benefit for us implementing it like the latter? 🤔@cmarqu: Ah yes, the distinction between the settings didn't occur to me yet, but makes sense.

`suppress_warnings` isn't used in the Python docs yet, but maybe just because there has been no need. They don't use many third-party extensions, but a multi-purpose internal one ([`pyspecific`](https://github.com/python/cpython/blob/main/Doc/tools/extensions/pyspecific.py)) that they can tailor so that there aren't warnings with "good" docs.@felix-hilden: Alright 👍 I'd be inclined to suggest using "suppress warnings" then because it really is doing the same thing and we would just be reimplementing the if statements 😅

"Strict warnings with no failures on good docs" is a good goal too, and for that I would suggest skipping individual blocks instead. Then parsing errors don't fly under the radar.@felix-hilden: A few days worth of redoing our parsing code later I'm happy to tell you that it's been fixed 😅 On the bright side now it's way more sensible overall!

Solution patch:
diff --git a/docs/src/release_notes.rst b/docs/src/release_notes.rst
index 5322f4b..f69ac7e 100644
--- a/docs/src/release_notes.rst
+++ b/docs/src/release_notes.rst
@@ -8,6 +8,11 @@ These release notes are based on
 sphinx-codeautolink adheres to
 `Semantic Versioning <https://semver.org>`_.
 
+Unreleased
+----------
+- Fix attribute and call after walrus leading parser error (:issue:`174`)
+- Improve error message on uncaught parsing errors (:issue:`177`)
+
 0.17.0 (2025-02-18)
 -------------------
 Added
diff --git a/src/sphinx_codeautolink/extension/block.py b/src/sphinx_codeautolink/extension/block.py
index 79ec41c..d153509 100644
--- a/src/sphinx_codeautolink/extension/block.py
+++ b/src/sphinx_codeautolink/extension/block.py
@@ -196,7 +196,7 @@ class CodeBlockAnalyser(nodes.SparseNodeVisitor):
         """Visit a generic literal block."""
         return self.parse_source(node, node.get("language", self.highlight_lang))
 
-    def parse_source(  # noqa: C901
+    def parse_source(  # noqa: C901,PLR0912
         self, node: nodes.literal_block | nodes.doctest_block, language: str | None
     ) -> None:
         """Analyse Python code blocks."""
@@ -258,6 +258,12 @@ class CodeBlockAnalyser(nodes.SparseNodeVisitor):
             msg = self._parsing_error_msg(e, language, show_source)
             logger.warning(msg, type=warn_type, subtype="parse_block", location=node)
             return
+        except Exception as e:
+            show_source = self._format_source_for_error(
+                self.global_preface, self.concat_sources, prefaces, transform.source
+            )
+            msg = self._parsing_error_msg(e, language, show_source)
+            raise type(e)(msg) from e
 
         if prefaces or self.concat_sources or self.global_preface:
             concat_lens = [s.count("\n") + 1 for s in self.concat_sources]
@@ -302,6 +308,7 @@ class CodeBlockAnalyser(nodes.SparseNodeVisitor):
                 str(error) + f" in document {self.current_document!r}",
                 f"Parsed source in `{language}` block:",
                 source,
+                "",
             ]
         )
 
diff --git a/src/sphinx_codeautolink/parse.py b/src/sphinx_codeautolink/parse.py
index a0a6df8..c2687c5 100644
--- a/src/sphinx_codeautolink/parse.py
+++ b/src/sphinx_codeautolink/parse.py
@@ -5,11 +5,8 @@ from __future__ import annotations
 import ast
 import builtins
 import sys
-from collections.abc import Generator
-from contextlib import contextmanager
-from dataclasses import dataclass, field
+from dataclasses import dataclass
 from enum import Enum
-from functools import wraps
 from importlib import import_module
 
 from .warn import logger, warn_type
@@ -53,6 +50,8 @@ class Component:
             name = node.arg
         elif isinstance(node, ast.Call):
             name = NameBreak.call
+        elif isinstance(node, ast.NamedExpr):
+            name = NameBreak.namedexpr
         elif HAS_MATCH and isinstance(node, ast.MatchAs):
             name = node.name
             context = "store"
@@ -64,25 +63,11 @@ class Component:
 
 @dataclass
 class PendingAccess:
-    """Pending name access."""
+    """Name access chain pending to be resolved in the scope and recorded."""
 
     components: list[Component]
 
 
-@dataclass
-class AssignTarget:
-    """
-    Assign target.
-
-    `elements` represent the flattened assignment target elements.
-    If a single PendingAccess is found, it should be used to store the value
-    on the right hand side of the assignment. If multiple values are found,
-    the assignment contained unpacking, and only overwriting of names should occur.
-    """
-
-    elements: list[PendingAccess | None]
-
-
 @dataclass
 class Assignment:
     """
@@ -90,21 +75,29 @@ class Assignment:
 
     - ordinarily one value to a single target
     - multiple targets when chain assigning (a = b = c)
-    - nested assignments in walruses (a = b := c)
+
+    Target or value may be None if not trackable.
     """
 
-    targets: list[AssignTarget]
-    value: PendingAccess | Assignment | None
+    targets: list[PendingAccess | None]
+    value: PendingAccess | None
 
 
 class NameBreak(str, Enum):
-    """Elements that break name access chains."""
+    """
+    Elements that break name access chains.
+
+    The symbols are arbitrary, only avoiding being valid Python names
+    in order to not clash with actual named components in the chain.
+    """
 
     call = "()"
+    namedexpr = ":="
+    import_from = ">"
 
 
 class LinkContext(str, Enum):
-    """Context in which a link appears."""
+    """Context in which a link appears to help HTML matching."""
 
     none = "none"
     after_call = "after_call"
@@ -127,14 +120,10 @@ class Name:
 @dataclass
 class Access:
     """
-    Accessed import, to be broken down into suitable chunks.
+    Accessed import, whose tail is about to be recorded as a Name.
 
     :attr:`prior_components` are components that are implicitly used via
     the base name in :attr:`components`, which is the part that shows on the line.
-    :attr:`hidden_components` is an attribute of split Access, in which the
-    proper components are not moved to prior components to track which were
-    present on the line of the access.
-
     The base component that connects an import to the name that was used to
     access it is automatically removed from the components in :attr:`full_components`.
     """
@@ -142,28 +131,15 @@ class Access:
     context: LinkContext
     prior_components: list[Component]
     components: list[Component]
-    hidden_components: list[Component] = field(default_factory=list)
 
     @property
     def full_components(self) -> list[Component]:
         """All components from import base to used components."""
         if not self.prior_components:
             # Import statement itself
-            return self.hidden_components + self.components
+            return self.components
 
-        if self.hidden_components:
-            proper_components = self.hidden_components[1:] + self.components
-        else:
-            proper_components = self.components[1:]
-        return self.prior_components + proper_components
-
-    @property
-    def code_str(self) -> str:
-        """Code representation of components."""
-        break_on = set(NameBreak)
-        breaks = [i for i, c in enumerate(self.components) if c.name in break_on]
-        start_ix = breaks[-1] + 1 if breaks else 0
-        return ".".join(c.name for c in self.components[start_ix:])
+        return self.prior_components + self.components[1:]
 
     @property
     def lineno_span(self) -> tuple[int, int]:
@@ -172,65 +148,24 @@ class Access:
         max_ = max(c.end_lineno for c in self.components)
         return min_, max_
 
-    @staticmethod
-    def to_name(instance: Access) -> Name:
-        """Convert access to name."""
-        return Name(
-            [c.name for c in instance.full_components],
-            instance.code_str,
-            *instance.lineno_span,
-            context=instance.context,
-        )
-
-    def split(self) -> list[Name]:
-        """Split access into multiple names."""
-        # Copy to avoid modifying the instance in place
-        items = [
-            Access(
-                context=self.context,
-                prior_components=self.prior_components[:],
-                components=self.components[:],
-                hidden_components=self.hidden_components[:],
-            )
+    def to_name(self) -> Name:
+        """Convert access tail (from a break) into a Name."""
+        components = [
+            c.name
+            for c in self.full_components
+            if c.name not in (NameBreak.import_from, NameBreak.namedexpr)
         ]
-        while True:
-            current = items[-1]
-            for i, comp in enumerate(current.components):
-                if i and comp.name == NameBreak.call:
-                    hidden = current.hidden_components + current.components[:i]
-                    next_ = Access(
-                        LinkContext.after_call,
-                        current.prior_components,
-                        current.components[i:],
-                        hidden_components=hidden,
-                    )
-                    current.components = current.components[:i]
-                    items.append(next_)
-                    break
-            else:
-                break
-        if items[-1].components[-1].name == NameBreak.call:
-            items.pop()
-        return [self.to_name(i) for i in items]
-
 
-def track_parents(func):
-    """
-    Track a stack of nodes to determine the position of the current node.
-
-    Uses and increments the surrounding classes :attr:`_parents`.
-    """
+        breaks = [i for i, c in enumerate(self.components) if c.name in list(NameBreak)]
+        start_ix = breaks[-1] + 1 if breaks else 0
+        code_str = ".".join(c.name for c in self.components[start_ix:])
 
-    @wraps(func)
-    def wrapper(self: ImportTrackerVisitor, *args, **kwargs):
-        self._parents += 1
-        result = func(self, *args, **kwargs)
-        self._parents -= 1
-        if not self._parents:
-            self.dispatch_result(result)
-        return result
+        if breaks and self.components[breaks[-1]].name == NameBreak.call:
+            context = LinkContext.after_call
+        else:
+            context = self.context
 
-    return wrapper
+        return Name(components, code_str, *self.lineno_span, context=context)
 
 
 builtin_components: dict[str, list[Component]] = {
@@ -243,11 +178,10 @@ class ImportTrackerVisitor(ast.NodeVisitor):
 
     def __init__(self, doctree_node) -> None:
         super().__init__()
+        self.doctree_node = doctree_node
         self.accessed: list[Name] = []
         self.in_augassign = False
-        self._parents = 0
-        self._no_split = False
-        self.doctree_node = doctree_node
+        self._is_chaining = 0
 
         # Stack for dealing with class body pseudo scopes
         # which are completely bypassed by inner scopes (func, lambda).
@@ -259,37 +193,38 @@ class ImportTrackerVisitor(ast.NodeVisitor):
         # Holds references to the values of previous nesting levels.
         self.outer_scopes_stack: list[dict[str, list[Component]]] = []
 
-    def save_access(self, access: Access) -> None:
+    @property
+    def current_locals(self):
+        """Get the current local variables and their component chains."""
+        return self.pseudo_scopes_stack[-1]
+
+    def save_accessed_names(self, access: Access) -> None:
         """Convert Access to Names to store in the visitor for aggregation."""
-        names = access.split() if not self._no_split else [Access.to_name(access)]
-        self.accessed.extend(names)
-
-    @contextmanager
-    def no_split(self) -> Generator[None, None, None]:
-        """Disable splitting Accesses."""
-        self._no_split, old = (True, self._no_split)
-        yield
-        self._no_split = old
-
-    @contextmanager
-    def reset_parents(self) -> Generator[None, None, None]:
-        """Reset parents state for the duration of the context."""
-        self._parents, old = (0, self._parents)
-        yield
-        self._parents = old
-
-    # Nodes that are excempt from resetting parents in default visit
-    track_nodes = (ast.Name, ast.Attribute, ast.Call, ast.NamedExpr)
-    if HAS_MATCH:
-        track_nodes += (ast.MatchAs,)
-
-    def visit(self, node: ast.AST):
-        """Override default visit to track name access and assignments."""
-        if isinstance(node, self.track_nodes):
-            return super().visit(node)
-
-        with self.reset_parents():
-            return super().visit(node)
+        self.accessed.append(access.to_name())
+
+    def visit(self, node: ast.AST) -> PendingAccess | None:
+        """
+        Override default visit to track name access and assignments.
+
+        Only ast.Name and ast.Attribute form unbroken access chains.
+        We make sure those are processed unbroken first. After reaching
+        the end of the chain (or when encountering an assignment),
+        the access chains and assignments are resolved.
+        """
+        self._is_chaining, was_chaining = (
+            isinstance(node, ast.Name | ast.Attribute),
+            self._is_chaining,
+        )
+        result: Assignment | PendingAccess | None = super().visit(node)
+
+        if isinstance(result, PendingAccess) and self._is_chaining and not was_chaining:
+            result = self.resolve_pending_access(result)
+        elif isinstance(result, Assignment):
+            self.resolve_assignment(result)
+            result = None
+
+        self._is_chaining = was_chaining
+        return result
 
     def overwrite_name(self, name: str) -> None:
         """Overwrite name in current scope."""
@@ -299,60 +234,54 @@ class ImportTrackerVisitor(ast.NodeVisitor):
         # that we could follow, but for now it's not really worth the effort.
         # With a dotted value, the following condition will never hold as long
         # as the dotted components of imports are discarded on creating the import.
-        self.pseudo_scopes_stack[-1].pop(name, None)
+        self.current_locals.pop(name, None)
 
     def assign_name(self, name: str, components: list[Component]) -> None:
         """Import or assign a name to current scope."""
         # Overwriting technically unnecessary until it properly follows dots
         self.overwrite_name(name)
-        self.pseudo_scopes_stack[-1][name] = components
+        for c in components:
+            c.context = "load"
+
+        prev_comps = self.current_locals.get(components[0].name)
+        if prev_comps:
+            components = prev_comps + components[1:]
+        self.current_locals[name] = components
 
-    def create_access(
-        self, scope_key: str, new_components: list[Component]
-    ) -> Access | None:
+    def _create_access(self, components: list[Component]) -> PendingAccess | None:
         """Create access from scope."""
-        prior = self.pseudo_scopes_stack[-1].get(scope_key, None)
+        scope_key = components[0].name
+        prior = self.current_locals.get(scope_key)
         if prior is None:
             return None
 
-        access = Access(LinkContext.none, prior, new_components)
-        self.save_access(access)
-        return access
+        access = Access(LinkContext.none, prior, components)
+        self.save_accessed_names(access)
+        return PendingAccess(components=components)
 
-    def resolve_pending_access(self, pending: PendingAccess) -> Access | None:
+    def resolve_pending_access(self, pending: PendingAccess) -> PendingAccess | None:
         """Resolve and save pending access."""
         components = pending.components
 
         context = components[0].context
         if context == "store" and not self.in_augassign:
             self.overwrite_name(components[0].name)
-            return None
+            return pending
 
-        access = self.create_access(components[0].name, components)
+        access = self._create_access(components)
         if context == "del":
             self.overwrite_name(components[0].name)
         return access
 
-    def resolve_assignment(self, assignment: Assignment) -> Access | None:
+    def resolve_assignment(self, assignment: Assignment) -> None:
         """Resolve access for assignment values and targets."""
-        access = self.dispatch_result(assignment.value)
-        self._resolve_assign_targets(assignment, access)
-        return access
-
-    def _resolve_assign_targets(self, assignment: Assignment, access: Access) -> None:
-        for assign in assignment.targets:
-            if assign is None:
-                continue
-
-            # On multiple nested targets, only overwrite assigned names
-            value = access if len(assign.elements) <= 1 else None
-
-            for target in assign.elements:
-                self._resolve_assign_target(target, value)
+        for target in assignment.targets:
+            self.resolve_single_assign_target(target, assignment.value)
 
-    def _resolve_assign_target(
-        self, target: PendingAccess | None, value: Access | None
+    def resolve_single_assign_target(
+        self, target: PendingAccess | None, value: PendingAccess | None
     ) -> None:
+        """Assign value to a single assignment target."""
         if target is None:
             return
 
@@ -361,25 +290,15 @@ class ImportTrackerVisitor(ast.NodeVisitor):
             if value is None:
                 self.overwrite_name(comp.name)
             else:
-                self.assign_name(comp.name, value.full_components)
-                self.create_access(comp.name, target.components)
+                self.assign_name(comp.name, value.components)
+                self._create_access(target.components)
         else:
             self.resolve_pending_access(target)
 
     def create_simple_access(self, name: str, lineno: int) -> None:
         """Create single-component access to scope."""
         component = Component(name, lineno, lineno, "load")
-        self.create_access(component.name, [component])
-
-    def dispatch_result(
-        self, result: PendingAccess | Assignment | None
-    ) -> Access | None:
-        """Determine the appropriate processing after tracking an access chain."""
-        if isinstance(result, Assignment):
-            return self.resolve_assignment(result)
-        if isinstance(result, PendingAccess):
-            return self.resolve_pending_access(result)
-        return None
+        self._create_access([component])
 
     def visit_Global(self, node: ast.Global) -> None:
         """Import from top scope."""
@@ -429,16 +348,23 @@ class ImportTrackerVisitor(ast.NodeVisitor):
 
         prefix_parts = prefix.rstrip(".").split(".") if prefix else []
         prefix_components = [Component(n, *linenos(node), "load") for n in prefix_parts]
-        if prefix:
-            self.save_access(Access(LinkContext.import_from, [], prefix_components))
+        if prefix_components:
+            self.save_accessed_names(
+                Access(LinkContext.import_from, [], prefix_components)
+            )
+            prefix_components.append(
+                Component(NameBreak.import_from, *linenos(node), "load")
+            )
 
         for import_name, alias in zip(import_names, aliases, strict=True):
             if not import_star:
                 components = [
                     Component(n, *linenos(node), "load") for n in import_name.split(".")
                 ]
-                self.save_access(
-                    Access(LinkContext.import_target, [], components, prefix_components)
+                self.save_accessed_names(
+                    Access(
+                        LinkContext.import_target, [], prefix_components + components
+                    )
                 )
 
             if not alias and "." in import_name:
@@ -460,79 +386,54 @@ class ImportTrackerVisitor(ast.NodeVisitor):
         else:
             self.visit_Import(node, prefix=node.module + ".")
 
-    @track_parents
     def visit_Name(self, node: ast.Name) -> PendingAccess:
-        """Visit a Name node."""
+        """Create the initial pending access chain."""
         return PendingAccess([Component.from_ast(node)])
 
-    @track_parents
     def visit_Attribute(self, node: ast.Attribute) -> PendingAccess | None:
-        """Visit an Attribute node."""
+        """Add attribute access to an existing access chain."""
         inner: PendingAccess | None = self.visit(node.value)
         if inner is not None:
             inner.components.append(Component.from_ast(node))
         return inner
 
-    @track_parents
     def visit_Call(self, node: ast.Call) -> PendingAccess | None:
-        """Visit a Call node."""
+        """Add call to an existing access chain and separately visit args."""
         inner: PendingAccess | None = self.visit(node.func)
         if inner is not None:
             inner.components.append(Component.from_ast(node))
-        with self.reset_parents():
-            for arg in node.args + node.keywords:
-                self.visit(arg)
-            if hasattr(node, "starargs"):
-                self.visit(node.starargs)
-            if hasattr(node, "kwargs"):
-                self.visit(node.kwargs)
+        for arg in node.args + node.keywords:
+            self.visit(arg)
         return inner
 
-    @track_parents
-    def visit_Tuple(self, node: ast.Tuple) -> list[PendingAccess] | None:
-        """Visit a Tuple node."""
-        if isinstance(node.ctx, ast.Store):
-            accesses = []
-            for element in node.elts:
-                ret = self.visit(element)
-                if isinstance(ret, PendingAccess) or ret is None:
-                    accesses.append(ret)
-                else:
-                    accesses.extend(ret)
-            return accesses
-        with self.reset_parents():
-            for element in node.elts:
-                self.visit(element)
-        return None
-
-    @track_parents
+    def visit_Tuple(self, node: ast.Tuple) -> None:
+        """Override stored values as tuple assignment is not supported."""
+        for element in node.elts:
+            result = self.visit(element)
+            if isinstance(node.ctx, ast.Store) and isinstance(result, PendingAccess):
+                self.resolve_single_assign_target(result, None)
+
     def visit_Assign(self, node: ast.Assign) -> Assignment:
         """Visit an Assign node."""
         value = self.visit(node.value)
         targets = []
         for n in node.targets[::-1]:
             target = self.visit(n)
-            if not isinstance(target, list):
-                target = [target]
-            targets.append(AssignTarget(target))
+            targets.append(target)
         return Assignment(targets, value)
 
-    @track_parents
     def visit_AnnAssign(self, node: ast.AnnAssign) -> Assignment:
         """Visit an AnnAssign node."""
         value = self.visit(node.value) if node.value is not None else None
         annot = self.visit(node.annotation)
         if annot is not None:
-            if value is not None:
-                self.resolve_pending_access(value)
-
             annot.components.append(
                 Component(NameBreak.call, *linenos(node.annotation), "load")
             )
             value = annot
 
         target = self.visit(node.target)
-        return Assignment([AssignTarget([target])], value)
+        return Assignment([target], value)
 
     def visit_AugAssign(self, node: ast.AugAssign) -> None:
         """Visit an AugAssign node."""
@@ -541,45 +442,56 @@ class ImportTrackerVisitor(ast.NodeVisitor):
         self.visit(node.target)
         self.in_augassign = temp
 
-    @track_parents
-    def visit_NamedExpr(self, node: ast.NamedExpr) -> Assignment:
+    def visit_NamedExpr(self, node: ast.NamedExpr) -> PendingAccess:
         """Visit a NamedExpr node."""
         value = self.visit(node.value)
         target = self.visit(node.target)
-        return Assignment([AssignTarget([target])], value)
+        assign = Assignment([target], value)
+        self.resolve_assignment(assign)
+        if value is not None:
+            value.components.append(Component.from_ast(node))
+        return value
 
-    @track_parents
     def visit_MatchClass(self, node: ast.AST) -> None:
         """Visit a match case class as a series of assignments."""
-        with self.reset_parents():
-            cls = self.visit(node.cls)
+        cls = self.visit(node.cls)
 
-        accesses = []
+        pattern_accesses = []
         for n in node.patterns:
             access = self.visit(n)
             if access is not None:
-                accesses.append(access)
+                pattern_accesses.append(access)
+
+        kwd_accesses = []
+        for attr in node.kwd_attrs:
+            if cls is None:
+                kwd_accesses.append(None)
+                continue
 
-        assigns = []
-        for attr, pattern in zip(node.kwd_attrs, node.kwd_patterns, strict=True):
-            target = self.visit(pattern)
             attr_comps = [
                 Component(NameBreak.call, *linenos(node), "load"),
                 Component(attr, *linenos(node), "load"),
             ]
             access = PendingAccess(cls.components + attr_comps)
-            assigns.append(Assignment([AssignTarget([target])], access))
+            kwd_accesses.append(access)
 
-        for access in accesses:
-            self.resolve_pending_access(access)
+        for access in pattern_accesses + kwd_accesses:
+            if access is not None:
+                self.resolve_pending_access(access)
 
-        with self.no_split():
-            for assign in assigns:
-                self.resolve_assignment(assign)
+        assigns = []
+        for access, pattern in zip(kwd_accesses, node.kwd_patterns, strict=True):
+            target = self.visit(pattern)
+            if cls is not None and target is not None:
+                assigns.append(Assignment([target], access))
+
+        for assign in assigns:
+            self.resolve_assignment(assign)
 
-    @track_parents
-    def visit_MatchAs(self, node: ast.AST) -> PendingAccess:
+    def visit_MatchAs(self, node: ast.AST) -> PendingAccess | None:
         """Track match alias names."""
+        if node.name is None:
+            return None
         return PendingAccess([Component.from_ast(node)])
 
     def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
@@ -645,7 +557,6 @@ class ImportTrackerVisitor(ast.NodeVisitor):
             inner.visit(n)
         self.accessed.extend(inner.accessed)
 
-    @track_parents
     def visit_arg(self, arg: ast.arg) -> Assignment:
         """Handle function argument and its annotation."""
         target = PendingAccess([Component.from_ast(arg)])
@@ -657,7 +568,7 @@ class ImportTrackerVisitor(ast.NodeVisitor):
                 )
         else:
             value = None
-        return Assignment([AssignTarget([target])], value)
+        return Assignment([target], value)
 
     def visit_Lambda(self, node: ast.Lambda) -> None:
         """Swap node order and separate inner scope."""
@@ -705,7 +616,7 @@ class ImportTrackerVisitor(ast.NodeVisitor):
     ) -> None:
         """Separate inner scope, respects class body scope."""
         inner = self.__class__(self.doctree_node)
-        inner.pseudo_scopes_stack[0] = self.pseudo_scopes_stack[-1].copy()
+        inner.pseudo_scopes_stack[0] = self.current_locals.copy()
         for gen in generators:
             inner.visit(gen)
         for value in values:


Test patch:
diff --git a/tests/extension/__init__.py b/tests/extension/__init__.py
index 076ed0e..d036668 100644
--- a/tests/extension/__init__.py
+++ b/tests/extension/__init__.py
@@ -192,6 +192,7 @@ But edited.
 
 
 def test_build_twice_and_delete_one_file(tmp_path: Path):
+    conf = default_conf + "\nsuppress_warnings = ['toc.not_readable']"
     index = """
 Test project
 ------------
@@ -213,7 +214,7 @@ Another
 .. autolink-examples:: test_project.bar
 """
 
-    files = {"conf.py": default_conf, "index.rst": index, "another.rst": another}
+    files = {"conf.py": conf, "index.rst": index, "another.rst": another}
     _sphinx_build(tmp_path, "html", files)
     (tmp_path / "src" / "another.rst").unlink()
     _sphinx_build(tmp_path, "html", {})
@@ -240,7 +241,7 @@ Test project
     def raise_nomsg(*_, **__):
         raise ValueError
 
-    target = "sphinx_codeautolink.extension.CodeBlockAnalyser"
+    target = "sphinx_codeautolink.parse.ImportTrackerVisitor"
     with pytest.raises(RuntimeError), patch(target, raise_msg):
         _sphinx_build(tmp_path, "html", files)
 
diff --git a/tests/parse/__init__.py b/tests/parse/__init__.py
index 53146f8..a516a53 100644
--- a/tests/parse/__init__.py
+++ b/tests/parse/__init__.py
@@ -135,6 +135,12 @@ class TestSimple:
         refs = [("a.b", "a.b"), ("a.b.c", "a.b.c")]
         return s, refs
 
+    @refs_equal
+    def test_dotted_import_then_call_attrib(self):
+        s = "import a.b\na.b().c"
+        refs = [("a.b", "a.b"), ("a.b", "a.b"), ("a.b.().c", "c")]
+        return s, refs
+
     @refs_equal
     def test_relative_import_is_noop(self):
         s = "from .a import b\nb"
diff --git a/tests/parse/_util.py b/tests/parse/_util.py
index bff875a..e53baec 100644
--- a/tests/parse/_util.py
+++ b/tests/parse/_util.py
@@ -25,7 +25,11 @@ def refs_equal(func):
         for components, code_str in expected:
             print(f"components={components}, code_str={code_str}")
         print("\nParsed names:")
-        [print(n) for n in names]
+        for n in names:
+            print(
+                f"components={'.'.join(n.import_components)}, code_str={n.code_str},"
+                f"lines=({n.lineno}, {n.end_lineno})"
+            )
         for n, e in zip(names, expected, strict=True):
             s = ".".join(c for c in n.import_components)
             assert s == e[0], f"Wrong import! Expected\n{e}\ngot\n{n}"
diff --git a/tests/parse/assign.py b/tests/parse/assign.py
index e43d981..e8b660e 100644
--- a/tests/parse/assign.py
+++ b/tests/parse/assign.py
@@ -194,6 +194,18 @@ class TestAssign:
         refs = [("a", "a"), ("a", "a"), ("a", "b"), ("a", "c")]
         return s, refs
 
+    @refs_equal
+    def test_walrus_expr_used_in_attribute(self):
+        s = "import a\n(b := a).c"
+        refs = [("a", "a"), ("a", "a"), ("a", "b"), ("a.c", "c")]
+        return s, refs
+
+    @refs_equal
+    def test_walrus_expr_used_in_call(self):
+        s = "import a\n(b := a)().c"
+        refs = [("a", "a"), ("a", "a"), ("a", "b"), ("a.().c", "c")]
+        return s, refs
+
     @refs_equal
     def test_dotted_import_overwrites_all_components(self):
         s = "class a:\n  b = 1\nimport a.b\na.b"
@@ -215,6 +227,12 @@ class TestFollowAssignment:
         refs = [("a", "a"), ("a", "a"), ("a", "b"), ("a", "b")]
         return s, refs
 
+    @refs_equal
+    def test_follow_double_assign(self):
+        s = "import a\nb = a\nc = b\nc"
+        refs = [("a", "a"), ("a", "a"), ("a", "b"), ("a", "b"), ("a", "c"), ("a", "c")]
+        return s, refs
+
     @refs_equal
     def test_follow_simple_assign_attr(self):
         s = "import a\nb = a\nb.c"