Repository: Textualize/textual

Issue:
[MarkdownViewer] opening an anchor link results in a crash
With `MarkdownViewer` clicking on any link that has an anchor link `uri/path.md#-someanchor` will raise `FileNotFoundError`.

Anchor links are pretty common in markdown and it's not always possible to control the content of the file being read as long as it is valid md. I was thinking this is more of a bug than an enhancement, it should be at least handled by opening the target file properly.

There is  #2941 as a fix proposal, I am willing to fix/update the PR as needed.

https://github.com/Textualize/textual/assets/210457/22693250-b9fb-4f75-ba72-334d029f7744

## Makrdown file:
```
<!-- Instrukt documentation master file, created by
sphinx-quickstart on Mon Jul 10 21:22:32 2023.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive. -->

* [Quickstart](quickstart.md)
  * [Introduction](quickstart.md#introduction)
    * [Custom Agents](quickstart.md#custom-agents)
  * [User Interface](quickstart.md#user-interface)
    * [Console Panel](quickstart.md#console-panel)
    * [Monitor Panel](quickstart.md#monitor-panel)
    * [Agent Output](quickstart.md#agent-output)
    * [Realm Window](quickstart.md#realm-window)
  * [Index Management](quickstart.md#index-management)
  * [FAQ](quickstart.md#faq)
    * [Terminal Colors](quickstart.md#terminal-colors)
    * [Select And Copy](quickstart.md#select-and-copy)
  * [Nerdfonts Icons](quickstart.md#nerdfonts-icons)
* [Installation Guide](install.md)
  * [Minimum requirements](install.md#minimum-requirements)
  * [Installing from source](install.md#installing-from-source)
  * [Extra Dependencies](install.md#extra-dependencies)
```


Discussion:
Aiming to fix this for the next release, and #2941 seems like the best approach. We'll get that PR passing tests and I think it'll be good to go.

Resolving patch:
diff --git a/examples/markdown.py b/examples/markdown.py
--- a/examples/markdown.py
+++ b/examples/markdown.py
@@ -1,4 +1,5 @@
 from pathlib import Path
+from sys import argv
 
 from textual.app import App, ComposeResult
 from textual.reactive import var
@@ -44,4 +45,6 @@ async def action_forward(self) -> None:
 
 if __name__ == "__main__":
     app = MarkdownApp()
+    if len(argv) > 1 and Path(argv[1]).exists():
+        app.path = Path(argv[1])
     app.run()
diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py
--- a/src/textual/widgets/_markdown.py
+++ b/src/textual/widgets/_markdown.py
@@ -12,6 +12,7 @@
 from rich.text import Text
 from typing_extensions import TypeAlias
 
+from .._slug import TrackedSlugs
 from ..app import ComposeResult
 from ..containers import Horizontal, Vertical, VerticalScroll
 from ..events import Mount
@@ -50,6 +51,10 @@ def go(self, path: str | PurePath) -> Path:
         Returns:
             New location.
         """
+        location, anchor = Markdown.sanitize_location(str(path))
+        if location == Path(".") and anchor:
+            current_file, _ = Markdown.sanitize_location(str(self.location))
+            path = f"{current_file}#{anchor}"
         new_path = self.location.parent / Path(path)
         self.stack = self.stack[: self.index + 1]
         new_path = new_path.absolute()
@@ -564,6 +569,7 @@ def __init__(
         super().__init__(name=name, id=id, classes=classes)
         self._markdown = markdown
         self._parser_factory = parser_factory
+        self._table_of_contents: TableOfContentsType | None = None
 
     class TableOfContentsUpdated(Message):
         """The table of contents was updated."""
@@ -628,6 +634,42 @@ def _on_mount(self, _: Mount) -> None:
         if self._markdown is not None:
             self.update(self._markdown)
 
+    @staticmethod
+    def sanitize_location(location: str) -> tuple[Path, str]:
+        """Given a location, break out the path and any anchor.
+
+        Args:
+            location: The location to sanitize.
+
+        Returns:
+            A tuple of the path to the location cleaned of any anchor, plus
+            the anchor (or an empty string if none was found).
+        """
+        location, _, anchor = location.partition("#")
+        return Path(location), anchor
+
+    def goto_anchor(self, anchor: str) -> None:
+        """Try and find the given anchor in the current document.
+
+        Args:
+            anchor: The anchor to try and find.
+
+        Note:
+            The anchor is found by looking at all of the headings in the
+            document and finding the first one whose slug matches the
+            anchor.
+
+            Note that the slugging method used is similar to that found on
+            GitHub.
+        """
+        if not self._table_of_contents or not isinstance(self.parent, Widget):
+            return
+        unique = TrackedSlugs()
+        for _, title, header_id in self._table_of_contents:
+            if unique.slug(title) == anchor:
+                self.parent.scroll_to_widget(self.query_one(f"#{header_id}"), top=True)
+                return
+
     async def load(self, path: Path) -> None:
         """Load a new Markdown document.
 
@@ -641,7 +683,10 @@ async def load(self, path: Path) -> None:
             The exceptions that can be raised by this method are all of
             those that can be raised by calling [`Path.read_text`][pathlib.Path.read_text].
         """
+        path, anchor = self.sanitize_location(str(path))
         await self.update(path.read_text(encoding="utf-8"))
+        if anchor:
+            self.goto_anchor(anchor)
 
     def unhandled_token(self, token: Token) -> MarkdownBlock | None:
         """Process an unhandled token.
@@ -672,7 +717,7 @@ def update(self, markdown: str) -> AwaitMount:
         )
 
         block_id: int = 0
-        table_of_contents: TableOfContentsType = []
+        self._table_of_contents = []
 
         for token in parser.parse(markdown):
             if token.type == "heading_open":
@@ -721,7 +766,7 @@ def update(self, markdown: str) -> AwaitMount:
                 if token.type == "heading_close":
                     heading = block._text.plain
                     level = int(token.tag[1:])
-                    table_of_contents.append((level, heading, block.id))
+                    self._table_of_contents.append((level, heading, block.id))
                 if stack:
                     stack[-1]._blocks.append(block)
                 else:
@@ -801,7 +846,9 @@ def update(self, markdown: str) -> AwaitMount:
                 if external is not None:
                     (stack[-1]._blocks if stack else output).append(external)
 
-        self.post_message(Markdown.TableOfContentsUpdated(self, table_of_contents))
+        self.post_message(
+            Markdown.TableOfContentsUpdated(self, self._table_of_contents)
+        )
         with self.app.batch_update():
             self.query("MarkdownBlock").remove()
             return self.mount_all(output)
@@ -952,7 +999,13 @@ def _on_mount(self, _: Mount) -> None:
 
     async def go(self, location: str | PurePath) -> None:
         """Navigate to a new document path."""
-        await self.document.load(self.navigator.go(location))
+        path, anchor = self.document.sanitize_location(str(location))
+        if path == Path(".") and anchor:
+            # We've been asked to go to an anchor but with no file specified.
+            self.document.goto_anchor(anchor)
+        else:
+            # We've been asked to go to a file, optionally with an anchor.
+            await self.document.load(self.navigator.go(location))
 
     async def back(self) -> None:
         """Go back one level in the history."""


Test patch:
diff --git a/tests/test_markdownviewer.py b/tests/test_markdownviewer.py
new file mode 100644
--- /dev/null
+++ b/tests/test_markdownviewer.py
@@ -0,0 +1,64 @@
+from pathlib import Path
+
+import pytest
+
+from textual.app import App, ComposeResult
+from textual.geometry import Offset
+from textual.widgets import Markdown, MarkdownViewer
+
+TEST_MARKDOWN = """\
+* [First]({{file}}#first)
+* [Second](#second)
+
+# First
+
+The first.
+
+# Second
+
+The second.
+"""
+
+
+class MarkdownFileViewerApp(App[None]):
+    def __init__(self, markdown_file: Path) -> None:
+        super().__init__()
+        self.markdown_file = markdown_file
+        markdown_file.write_text(TEST_MARKDOWN.replace("{{file}}", markdown_file.name))
+
+    def compose(self) -> ComposeResult:
+        yield MarkdownViewer()
+
+    async def on_mount(self) -> None:
+        self.query_one(MarkdownViewer).show_table_of_contents = False
+        await self.query_one(MarkdownViewer).go(self.markdown_file)
+
+
+@pytest.mark.parametrize("link", [0, 1])
+async def test_markdown_file_viewer_anchor_link(tmp_path, link: int) -> None:
+    """Test https://github.com/Textualize/textual/issues/3094"""
+    async with MarkdownFileViewerApp(Path(tmp_path) / "test.md").run_test() as pilot:
+        # There's not really anything to test *for* here, but the lack of an
+        # exception is the win (before the fix this is testing it would have
+        # been FileNotFoundError).
+        await pilot.click(Markdown, Offset(2, link))
+
+
+class MarkdownStringViewerApp(App[None]):
+    def compose(self) -> ComposeResult:
+        yield MarkdownViewer(TEST_MARKDOWN.replace("{{file}}", ""))
+
+    async def on_mount(self) -> None:
+        self.query_one(MarkdownViewer).show_table_of_contents = False
+
+
+@pytest.mark.parametrize("link", [0, 1])
+async def test_markdown_string_viewer_anchor_link(link: int) -> None:
+    """Test https://github.com/Textualize/textual/issues/3094
+
+    Also https://github.com/Textualize/textual/pull/3244#issuecomment-1710278718."""
+    async with MarkdownStringViewerApp().run_test() as pilot:
+        # There's not really anything to test *for* here, but the lack of an
+        # exception is the win (before the fix this is testing it would have
+        # been FileNotFoundError).
+        await pilot.click(Markdown, Offset(2, link))