From c9dee58aa014cd96a5c35cc70f44332dcea93005 Mon Sep 17 00:00:00 2001 From: Filipe Brandenburger Date: Fri, 1 May 2026 08:17:50 -0700 Subject: [PATCH 1/4] Add regression test for Style.__add__ cache-conflation with equal link styles Two Style(link=...) objects with the same URL are equal by hash and comparison, but carry different _link_id values. Combining each with a base style must yield combined styles with distinct _link_ids; the lru_cache must not cause the second result to inherit the first's _link_id. --- tests/test_style.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_style.py b/tests/test_style.py index 84a65a6774..0e38dfce31 100644 --- a/tests/test_style.py +++ b/tests/test_style.py @@ -265,3 +265,21 @@ def test_clear_meta_and_links_clears_hash(): clear_style = style.clear_meta_and_links() assert clear_style._hash is None + + +def test_add_distinct_link_ids_for_equal_link_styles(): + """Regression test for the cache-conflation issue when lru_cache was introduced. + + Two Style(link=...) objects with the same URL are equal by hash and + comparison, but carry different _link_id values. Combining each with a base + style must yield combined styles with distinct _link_ids; the lru_cache + must not cause the second result to inherit the first's _link_id. + """ + + s1 = Style(link="https://example.com") + s2 = Style(link="https://example.com") + assert s1 == s2 # same hash and content + assert s1.link_id != s2.link_id # but distinct link ids + + base = Style(bold=True) + assert (base + s1).link_id != (base + s2).link_id From 199ce5b17ac3c763afc30a51c91dc479d9633729 Mon Sep 17 00:00:00 2001 From: Filipe Brandenburger Date: Fri, 1 May 2026 08:16:54 -0700 Subject: [PATCH 2/4] Fix hyperlinks being split into multiple links when text is highlighted Style.__add__ was calling copy() on every combined style that contained a link, which generated a fresh link_id for each highlighted sub-span within a single link markup region. Terminals use link_id to identify hyperlink boundaries, so each sub-span appeared as a separate clickable link instead of one continuous one. The underlying root cause is that _add's lru_cache keys on __hash__/__eq__, which intentionally excludes link_id. Two Style objects with the same visual content but different link_ids therefore collide in the cache; copy() was a blunt workaround that overcorrected by regenerating the id on every hit. Fix this by having _add take the link_id as an argument so that the lru_cache will now also key by link_id, preventing the issue that required the copy() workaround altogether. --- rich/style.py | 11 +++++++---- tests/test_highlighter.py | 28 ++++++++++++++++++++++++++++ tests/test_style.py | 36 +++++++++++++++++++++++++++++++++++- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/rich/style.py b/rich/style.py index 3806a8c4b9..c6f2391c52 100644 --- a/rich/style.py +++ b/rich/style.py @@ -730,7 +730,7 @@ def test(self, text: Optional[str] = None) -> None: sys.stdout.write(f"{self.render(text)}\n") @lru_cache(maxsize=1024) - def _add(self, style: Optional["Style"]) -> "Style": + def _add(self, style: Optional["Style"], link_id: str) -> "Style": if style is None or style._null: return self if self._null: @@ -745,7 +745,7 @@ def _add(self, style: Optional["Style"]) -> "Style": ) new_style._set_attributes = self._set_attributes | style._set_attributes new_style._link = style._link or self._link - new_style._link_id = style._link_id or self._link_id + new_style._link_id = link_id new_style._null = style._null if self._meta and style._meta: new_style._meta = dumps({**self.meta, **style.meta}) @@ -755,8 +755,11 @@ def _add(self, style: Optional["Style"]) -> "Style": return new_style def __add__(self, style: Optional["Style"]) -> "Style": - combined_style = self._add(style) - return combined_style.copy() if combined_style.link else combined_style + if style is not None and style._link_id: + link_id = style._link_id + else: + link_id = self._link_id + return self._add(style, link_id) NULL_STYLE = Style() diff --git a/tests/test_highlighter.py b/tests/test_highlighter.py index d19928274a..de26e8c28b 100644 --- a/tests/test_highlighter.py +++ b/tests/test_highlighter.py @@ -461,3 +461,31 @@ def test_highlight_iso8601_regex(test: str, spans: List[Span]): highlighter.highlight(text) print(text.spans) assert text.spans == spans + + +def test_highlighter_preserves_link_across_spans(): + """Ensure a link with highlighted text tokens renders as a single link. + + A hyperlink whose text contains tokens the ReprHighlighter recognises + (UUID, path, filename) must render as a single clickable link. + """ + from rich.console import Console + + markup = ( + "Error in job [link=https://www.example.com/issues/42]" + "78351748-9b32-4e08-ad3e-7e9ff124d541: see /var/log/myapp.log" + "[/link] for details" + ) + + console = Console(highlight=True) + text = console.render_str(markup) + + link_ids = { + seg.style._link_id + for seg in console.render(text) + if seg.style and seg.style.link + } + + assert ( + len(link_ids) == 1 + ), f"Expected a single link_id across all highlighted spans, got {len(link_ids)}" diff --git a/tests/test_style.py b/tests/test_style.py index 0e38dfce31..c35f03dee8 100644 --- a/tests/test_style.py +++ b/tests/test_style.py @@ -275,7 +275,6 @@ def test_add_distinct_link_ids_for_equal_link_styles(): style must yield combined styles with distinct _link_ids; the lru_cache must not cause the second result to inherit the first's _link_id. """ - s1 = Style(link="https://example.com") s2 = Style(link="https://example.com") assert s1 == s2 # same hash and content @@ -283,3 +282,38 @@ def test_add_distinct_link_ids_for_equal_link_styles(): base = Style(bold=True) assert (base + s1).link_id != (base + s2).link_id + + +def test_add_preserves_link_id_with_identical_meta(): + """Test that add preserves link_id. + + Two Style.from_meta() calls with identical content share the same hash, so + they collide in _add's lru_cache. Each must still produce a combined style + that carries its own distinct link_id. + """ + meta = {"click": "something"} + s1 = Style.from_meta(meta) + s2 = Style.from_meta(meta) + assert s1.link_id != s2.link_id # precondition: distinct ids + + base = Style(color="red") + assert (base + s1).link_id == s1.link_id + assert (base + s2).link_id == s2.link_id + + +def test_add_distinct_link_ids_with_identical_meta(): + """Test that adding with distinct link_id's will produce distinct list_id's. + + Regression test for Textualize/textual#1587. + + Two Style.from_meta() calls with identical content produce styles with different + _link_id values. Combining each with a base style must keep those ids distinct. + The cache must not cause the second result to inherit the first's link_id. + """ + meta = {"click": "something"} + s1 = Style.from_meta(meta) + s2 = Style.from_meta(meta) + assert s1.link_id != s2.link_id # precondition: distinct ids + + base = Style(color="red") + assert (base + s1).link_id != (base + s2).link_id From 5bda248a3b57360f9642479ff1e384b52bcfe3e3 Mon Sep 17 00:00:00 2001 From: Filipe Brandenburger Date: Fri, 1 May 2026 08:58:04 -0700 Subject: [PATCH 3/4] Add changelog entry for fixing hyperlinks being split into multiple links --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b016d4846..1c98c81bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- hyperlinks being split into multiple links when text is highlighted https://github.com/Textualize/rich/pull/4110 + ## [15.0.0] - 2026-04-12 ### Changed From c48c4d3bdbb0700bcc99f7843f6d04aa40019481 Mon Sep 17 00:00:00 2001 From: Filipe Brandenburger Date: Fri, 1 May 2026 08:58:35 -0700 Subject: [PATCH 4/4] Add myself to the list of contributors --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 2155a42a4d..5daf7d1269 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -101,3 +101,4 @@ The following people have contributed to the development of Rich: - [Alex Zheng](https://github.com/alexzheng111) - [Sebastian Speitel](https://github.com/SebastianSpeitel) - [Kevin Turcios](https://github.com/KRRT7) +- [Filipe Brandenburger](https://github.com/filbranden)