diff --git a/collins/linkpatterns.py b/collins/linkpatterns.py index 75651ef..1723b5f 100644 --- a/collins/linkpatterns.py +++ b/collins/linkpatterns.py @@ -13,6 +13,10 @@ ignored — the click falls through to the terminal. Bare filenames (``terminal.py``) are deliberately out of the grammar; without a slash the false-positive rate in ordinary prose is too high. +- ``bare_names_pattern``: the one sound way back in for bare filenames — an + alternation of *known* names (the entries actually sitting at a tab's + project root), so the hover underline can only ever land on a name that + exists. Built per terminal at runtime; see terminal._RootNameLinks. Both grammars stop at whitespace, quotes and brackets, and refuse to *end* on punctuation that prose tends to hang off a reference — so @@ -25,6 +29,7 @@ import os import re +from collections.abc import Iterable # One body-then-final-char pair per alternative: the greedy body backtracks # until the last character is something a URL can plausibly end on. @@ -58,6 +63,36 @@ f"{_LINE_SUFFIX}" ) +# Names the bare grammar can't take: whitespace can't be bounded by a regex +# at all, `:` would collide with the line suffix, and `/` belongs to the +# slashed grammar above. +_BARE_UNBOUNDABLE = re.compile("[\\s:/]") + + +def bare_names_pattern(names: Iterable[str]) -> str | None: + """A FILE_PATTERN companion matching any of *names* as a bare token. + + Bare filenames stay out of FILE_PATTERN because a *shape* can't help + over-matching prose — but an alternation of literal names underlines + only what genuinely exists, so the usual objection disappears. Same + boundary discipline as paths: a boundary (or line start) before, the + optional ``:line[:col]`` suffix after, and the token must not continue + with a character a path could end on — ``README.md.`` sheds its period + while ``README.mdx`` never half-matches an entry ``README.md``. Longest + name first, so an entry extending another wins the alternation + (``README.md.bak`` before ``README.md``). None when nothing survives + the unboundable-name filter: no regex to register at all. + """ + usable = sorted( + {name for name in names if name and not _BARE_UNBOUNDABLE.search(name)}, + key=lambda name: (-len(name), name), + ) + if not usable: + return None + alternatives = "|".join(re.escape(name) for name in usable) + return f"{_PATH_PRE}(?:{alternatives}){_LINE_SUFFIX}(?!{_PATH_FINAL})" + + _SUFFIX = re.compile(r"(.+?):(\d+)(?::(\d+))?") diff --git a/collins/terminal.py b/collins/terminal.py index 45a77a5..88ef0df 100644 --- a/collins/terminal.py +++ b/collins/terminal.py @@ -41,7 +41,12 @@ from .gitinfo import current_branch, has_changes # noqa: E402 from .i18n import _, ngettext # noqa: E402 from .lightbox import present_image_lightbox # noqa: E402 -from .linkpatterns import FILE_PATTERN, URL_PATTERN, resolve_file_reference # noqa: E402 +from .linkpatterns import ( # noqa: E402 + FILE_PATTERN, + URL_PATTERN, + bare_names_pattern, + resolve_file_reference, +) from .promptcard import build_question_card # noqa: E402 from .providers import Provider, get_provider # noqa: E402 from .prstatus import ( # noqa: E402 @@ -149,7 +154,9 @@ def _setup_links(terminal: Vte.Terminal) -> None: filesystem only at click time — VTE has no per-match callback, so the hover underline can't know whether the file exists, but a click on a candidate that resolves nowhere falls through to the terminal unclaimed - and costs the user nothing. + and costs the user nothing. Root-level files carry no slash, so the path + grammar can't take them; _RootNameLinks keeps one more regex built from + the names actually at the project root, so `README.md` underlines too. """ terminal.set_allow_hyperlink(True) tag_kinds: dict[int, str] = {} @@ -163,6 +170,7 @@ def _setup_links(terminal: Vte.Terminal) -> None: tag = terminal.match_add_regex(regex, 0) terminal.match_set_cursor_name(tag, "pointer") tag_kinds[tag] = kind + _RootNameLinks(terminal, tag_kinds) def on_pressed(gesture: Gtk.GestureClick, _n_press, x: float, y: float) -> None: if not gesture.get_current_event_state() & Gdk.ModifierType.CONTROL_MASK: @@ -226,6 +234,99 @@ def _reference_roots(terminal: Vte.Terminal) -> list[str | None]: return [tab.current_agent_cwd(), tab.editor_root] +class _RootNameLinks: + """_setup_links' bare-filename grammar: one extra match tag holding an + alternation of the names actually sitting at the tab's project root + (bare_names_pattern), so `README.md` underlines without the slash the + path grammar demands. Files only — root directory names (docs, tests) + are everyday prose words, and `docs/` is already the path grammar's. + + The root lives on the ancestor TerminalTab, which isn't an ancestor yet + while either terminal kind is being constructed — so the tag is built on + first map and the root never re-resolved (a tab's root is fixed for + life; panel bottom↔right swaps re-map without reparenting tabs). A + directory monitor keeps the alternation honest: on changes the names + are re-listed (debounced — a busy agent touches root files in bursts, + and most events are content writes that leave the name set alone) and + the tag swapped only when the set really changed. Between snapshots the + usual guarantees hold: a deleted file's click resolves nowhere and falls + through unclaimed. The terminal's signal closures keep the instance + alive; no one else needs to hold it.""" + + def __init__(self, terminal: Vte.Terminal, tag_kinds: dict[int, str]) -> None: + self._terminal = terminal + self._tag_kinds = tag_kinds + self._root: str | None = None + self._names: frozenset[str] | None = None + self._tag: int | None = None + self._monitor: Gio.FileMonitor | None = None + self._refresh_source: int | None = None + terminal.connect("map", self._on_map) + terminal.connect("destroy", self._on_destroy) + + def _on_map(self, _terminal: Vte.Terminal) -> None: + if self._root is not None: + return # re-mapped (panel swap); the tag and monitor live on + tab = self._terminal.get_ancestor(TerminalTab) + if tab is None: + return + self._root = tab.link_root + self._apply() + try: + self._monitor = Gio.File.new_for_path(self._root).monitor_directory( + Gio.FileMonitorFlags.NONE, None + ) + except GLib.Error: + return # no monitor backend: the map-time snapshot still serves + self._monitor.connect("changed", self._on_root_changed) + + def _on_root_changed(self, *_args) -> None: + if self._refresh_source is None: + self._refresh_source = GLib.timeout_add(500, self._refresh) + + def _refresh(self) -> bool: + self._refresh_source = None + self._apply() + return GLib.SOURCE_REMOVE + + def _apply(self) -> None: + names = self._file_names() + if names == self._names: + return + self._names = names + if self._tag is not None: + self._terminal.match_remove(self._tag) + del self._tag_kinds[self._tag] + self._tag = None + pattern = bare_names_pattern(names) + if pattern is None: + return + try: + regex = Vte.Regex.new_for_match(pattern, len(pattern.encode()), _PCRE2_MULTILINE) + except GLib.Error: + return # VTE built without PCRE2 (the static tags are gone too) + self._tag = self._terminal.match_add_regex(regex, 0) + self._terminal.match_set_cursor_name(self._tag, "pointer") + self._tag_kinds[self._tag] = "file" + + def _file_names(self) -> frozenset[str]: + try: + with os.scandir(self._root) as entries: + # is_dir follows symlinks, so a directory behind a link is + # excluded the same way a plain one is. + return frozenset(entry.name for entry in entries if not entry.is_dir()) + except OSError: + return frozenset() + + def _on_destroy(self, _terminal: Vte.Terminal) -> None: + if self._monitor is not None: + self._monitor.cancel() + self._monitor = None + if self._refresh_source is not None: + GLib.source_remove(self._refresh_source) + self._refresh_source = None + + def _open_file_reference( terminal: Vte.Terminal, path: str, line: int | None, col: int | None ) -> None: @@ -971,6 +1072,10 @@ def __init__( # a construct-on-demand race; HAVE_GTKSOURCE false leaves it None and # the footer button that would open it hidden (see _build_footer). editor_root = cwd if cwd and Path(cwd).is_dir() else str(Path.home()) + # Where bare root-name links look (_RootNameLinks): the directory the + # editor opens at, kept even when GtkSourceView (and so the editor + # itself, and the editor_root property) is missing. + self.link_root: str = editor_root self._editor = editor.EditorPane(editor_root) if editor.HAVE_GTKSOURCE else None self._editor_detached = False # pane reparented into its own EditorWindow self._editor_width = 0 # this tab's last-set editor width, px (0 = none yet) diff --git a/tests/test_linkpatterns.py b/tests/test_linkpatterns.py index bf15891..d3b8d47 100644 --- a/tests/test_linkpatterns.py +++ b/tests/test_linkpatterns.py @@ -9,7 +9,12 @@ import pytest -from collins.linkpatterns import FILE_PATTERN, URL_PATTERN, resolve_file_reference +from collins.linkpatterns import ( + FILE_PATTERN, + URL_PATTERN, + bare_names_pattern, + resolve_file_reference, +) _RX = re.compile(URL_PATTERN) _FILE_RX = re.compile(FILE_PATTERN) @@ -144,6 +149,92 @@ def test_urls_still_match_as_urls() -> None: assert _first_match(text) == text +# -- bare_names_pattern ------------------------------------------------------ + +_ROOT_NAMES = ["README.md", "pyproject.toml", "LICENSE", ".gitignore", "start-debug"] + + +def _first_bare_match(text: str, names: list[str] = _ROOT_NAMES) -> str | None: + pattern = bare_names_pattern(names) + assert pattern is not None + m = re.search(pattern, text) + return m.group(0) if m else None + + +@pytest.mark.parametrize( + "text,expected", + [ + ("open README.md please", "README.md"), + # At the very start of a line, like FILE_PATTERN's lookbehind. + ("README.md changed", "README.md"), + ("at README.md:12 there", "README.md:12"), + ("at README.md:12:5 there", "README.md:12:5"), + ("see `pyproject.toml` for deps", "pyproject.toml"), + ("read (LICENSE) first", "LICENSE"), + ("a hidden .gitignore too", ".gitignore"), + ("run start-debug now", "start-debug"), + ], +) +def test_bare_names_match(text: str, expected: str) -> None: + assert _first_bare_match(text) == expected + + +@pytest.mark.parametrize( + "text,expected", + [ + # The same ending discipline as the other grammars. + ("update the README.md.", "README.md"), + ("fixed README.md:12.", "README.md:12"), + ("README.md, then LICENSE", "README.md"), + ("is it README.md?", "README.md"), + ("quote 'LICENSE' end", "LICENSE"), + ], +) +def test_bare_name_sheds_trailing_punctuation(text: str, expected: str) -> None: + assert _first_bare_match(text) == expected + + +@pytest.mark.parametrize( + "text", + [ + # A known name must never half-match inside a longer token, in + # either direction. + "a README.mdx variant", + "xREADME.md glued on", + "the README.md-old backup", + # Slashed references are the path grammar's territory; the bare + # grammar must stay out (the lookbehind sees the slash). + "docs/README.md is a path", + "no names here at all", + ], +) +def test_bare_name_boundaries(text: str) -> None: + assert _first_bare_match(text) is None + + +def test_longer_entry_wins_over_its_prefix() -> None: + # Sorting inside bare_names_pattern, not caller order, decides: the + # alternation must try README.md.bak before README.md either way. + for names in (["README.md", "README.md.bak"], ["README.md.bak", "README.md"]): + assert _first_bare_match("see README.md.bak", names) == "README.md.bak" + + +def test_names_with_regex_metacharacters_match_literally() -> None: + assert _first_bare_match("open note[1]+x.md now", ["note[1]+x.md"]) == "note[1]+x.md" + # Unescaped, `[1]+` would read as a repeated class and admit this text; + # the literal must not. + assert _first_bare_match("open note11x.md now", ["note[1]+x.md"]) is None + + +def test_unboundable_names_are_filtered() -> None: + # Whitespace can't be bounded, `:` collides with the line suffix, `/` + # belongs to the path grammar; nothing usable means no pattern at all. + assert bare_names_pattern([]) is None + assert bare_names_pattern(["with space.txt", "a:b", "a/b", ""]) is None + # ...and unusable names don't poison the usable rest. + assert _first_bare_match("see README.md", ["with space.txt", "README.md"]) == "README.md" + + # -- resolve_file_reference ------------------------------------------------