"""Highlighting utilities for the tutorial."""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum, auto
from re import Pattern
from typing import NamedTuple
from rich.style import Style
class FocusType(Enum):
"""Types of focus patterns."""
LITERAL = auto()
REGEX = auto()
LINE = auto()
RANGE = auto()
STARTSWITH = auto()
BETWEEN = auto()
LINE_CONTAINING = auto()
LINE_CONTAINING_REGEX = auto()
SYNTAX = auto()
MARKDOWN = auto()
[docs]
@dataclass
class Focus:
"""A pattern to focus on with its style."""
pattern: str | Pattern | _RangeTuple | int | _StartsWithTuple | _BetweenTuple
style: Style = Style(color="yellow", bold=True) # noqa: RUF009
type: FocusType = FocusType.LITERAL
extra: dict | None = None
[docs]
@classmethod
def literal(
cls,
text: str,
style: Style = Style(color="yellow", bold=True), # noqa: B008
*,
word_boundary: bool = False,
match_index: int | list[int] | None = None,
) -> Focus:
"""Create a focus for a literal string.
Parameters
----------
text
The text to match
style
The style to apply to the matched text
word_boundary
If True, only match the text when it appears as a word
match_index
If provided, only highlight the nth match (0-based) or matches specified by the list.
If None, highlight all matches.
"""
if word_boundary:
pattern = re.compile(rf"\b{re.escape(text)}\b")
return cls(pattern, style, FocusType.REGEX, extra={"match_index": match_index})
return cls(text, style, FocusType.LITERAL, extra={"match_index": match_index})
[docs]
@classmethod
def regex(
cls,
pattern: str | Pattern,
style: Style = Style(color="green", bold=True), # noqa: B008
flags: re.RegexFlag = re.MULTILINE,
) -> Focus:
"""Create a focus for a regular expression."""
if isinstance(pattern, str):
pattern = re.compile(pattern, flags)
return cls(pattern, style, FocusType.REGEX)
[docs]
@classmethod
def line(
cls,
line_number: int,
style: Style = Style(color="cyan", bold=True), # noqa: B008
) -> Focus:
"""Create a focus for a line number."""
return cls(line_number, style, FocusType.LINE)
[docs]
@classmethod
def range(
cls,
start: int,
end: int,
style: Style = Style(color="magenta", bold=True), # noqa: B008
) -> Focus:
"""Create a focus for a range of characters."""
return cls(_RangeTuple(start, end), style, FocusType.RANGE)
[docs]
@classmethod
def startswith(
cls,
text: str,
style: Style = Style(color="blue", bold=True), # noqa: B008
*,
from_start_of_line: bool = False,
) -> Focus:
"""Create a focus for text that starts with the given pattern.
Parameters
----------
text
The text to match at the start
style
The style to apply to the matched text
from_start_of_line
If True, only match at the start of lines, if False match anywhere
"""
return cls(_StartsWithTuple(text, from_start_of_line), style, FocusType.STARTSWITH)
[docs]
@classmethod
def between(
cls,
start_pattern: str,
end_pattern: str,
style: Style = Style(color="blue", bold=True), # noqa: B008
*,
inclusive: bool = True,
multiline: bool = True,
match_index: int | None = None, # Add this parameter
greedy: bool = False, # Add this parameter
) -> Focus:
"""Create a focus for text between two patterns.
Parameters
----------
start_pattern
The pattern marking the start of the region
end_pattern
The pattern marking the end of the region
style
The style to apply to the matched text
inclusive
If True, include the start and end patterns in the highlighting
multiline
If True, match across multiple lines
match_index
If provided, only highlight the nth match (0-based).
If None, highlight all matches.
greedy
If True, use greedy matching (matches longest possible string).
If False, use non-greedy matching (matches shortest possible string).
"""
return cls(
_BetweenTuple(start_pattern, end_pattern, inclusive, multiline, match_index, greedy),
style,
FocusType.BETWEEN,
)
[docs]
@classmethod
def line_containing(
cls,
pattern: str,
style: Style | str = Style(color="yellow", bold=True), # noqa: B008
*,
lines_before: int = 0,
lines_after: int = 0,
regex: bool = False,
match_index: int | None = None,
) -> Focus:
"""Select the entire line containing a pattern and optionally surrounding lines.
Parameters
----------
pattern
The text pattern to search for.
style
The style to apply to the matched lines.
lines_before
Number of lines to include before the matched line.
lines_after
Number of lines to include after the matched line.
regex
If True, treat pattern as a regular expression.
match_index
If provided, only highlight the nth match (0-based).
If None, highlight all matches.
"""
if isinstance(style, str):
style = Style.parse(style)
return cls(
pattern=pattern,
style=style,
type=FocusType.LINE_CONTAINING_REGEX if regex else FocusType.LINE_CONTAINING,
extra={
"lines_before": lines_before,
"lines_after": lines_after,
"match_index": match_index,
},
)
[docs]
@classmethod
def syntax(
cls,
lexer: str = "python",
*,
theme: str | None = None,
line_numbers: bool = False,
start_line: int | None = None,
end_line: int | None = None,
) -> Focus:
"""Use Rich's syntax highlighting.
Parameters
----------
lexer
The language to use for syntax highlighting (default: "python")
theme
The color theme to use (default: None, uses terminal colors)
line_numbers
Whether to show line numbers
start_line
First line to highlight (0-based), if None highlight from start
end_line
Last line to highlight (0-based), if None highlight until end
"""
return cls(
pattern="", # Not used
style="", # Not used
type=FocusType.SYNTAX,
extra={
"lexer": lexer,
"theme": theme,
"line_numbers": line_numbers,
"start_line": start_line,
"end_line": end_line,
},
)
[docs]
@classmethod
def markdown(cls) -> Focus:
"""Create a focus for a Markdown block."""
return cls(
pattern="", # Not used
style="", # Not used
type=FocusType.MARKDOWN,
)
[docs]
def validate(self, focuses: list[Focus]) -> None:
"""Validate that there's at most one markdown or syntax focus."""
if self.type == FocusType.MARKDOWN:
if len([f for f in focuses if f.type == FocusType.MARKDOWN]) > 1:
msg = "Only one markdown focus is allowed per step."
raise ValueError(msg)
elif self.type == FocusType.SYNTAX: # noqa: SIM102
if len([f for f in focuses if f.type == FocusType.SYNTAX]) > 1:
msg = "Only one syntax focus is allowed per step."
raise ValueError(msg)
class _BetweenTuple(NamedTuple):
start_pattern: str
end_pattern: str
inclusive: bool
multiline: bool
match_index: int | None
greedy: bool
class _StartsWithTuple(NamedTuple):
text: str
from_start_of_line: bool
class _RangeTuple(NamedTuple):
start: int
end: int