Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
11 changes: 7 additions & 4 deletions rich/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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})
Expand All @@ -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()
Expand Down
28 changes: 28 additions & 0 deletions tests/test_highlighter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"
52 changes: 52 additions & 0 deletions tests/test_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,55 @@ 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


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