Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions collins/linkpatterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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+))?")


Expand Down
109 changes: 107 additions & 2 deletions collins/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] = {}
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
93 changes: 92 additions & 1 deletion tests/test_linkpatterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 ------------------------------------------------


Expand Down