You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
182 lines
5.5 KiB
182 lines
5.5 KiB
import re
|
|
from typing import Iterable, List, Match, NamedTuple, Optional, Tuple, Union
|
|
|
|
from .errors import MarkupError
|
|
from .style import Style
|
|
from .text import Span, Text
|
|
from ._emoji_replace import _emoji_replace
|
|
|
|
|
|
RE_TAGS = re.compile(
|
|
r"""((\\*)\[([a-z#\/].*?)\])""",
|
|
re.VERBOSE,
|
|
)
|
|
|
|
|
|
class Tag(NamedTuple):
|
|
"""A tag in console markup."""
|
|
|
|
name: str
|
|
"""The tag name. e.g. 'bold'."""
|
|
parameters: Optional[str]
|
|
"""Any additional parameters after the name."""
|
|
|
|
def __str__(self) -> str:
|
|
return (
|
|
self.name if self.parameters is None else f"{self.name} {self.parameters}"
|
|
)
|
|
|
|
@property
|
|
def markup(self) -> str:
|
|
"""Get the string representation of this tag."""
|
|
return (
|
|
f"[{self.name}]"
|
|
if self.parameters is None
|
|
else f"[{self.name}={self.parameters}]"
|
|
)
|
|
|
|
|
|
def escape(markup: str, _escape=re.compile(r"(\\*)(\[[a-z#\/].*?\])").sub) -> str:
|
|
"""Escapes text so that it won't be interpreted as markup.
|
|
|
|
Args:
|
|
markup (str): Content to be inserted in to markup.
|
|
|
|
Returns:
|
|
str: Markup with square brackets escaped.
|
|
"""
|
|
|
|
def escape_backslashes(match: Match[str]) -> str:
|
|
"""Called by re.sub replace matches."""
|
|
backslashes, text = match.groups()
|
|
return f"{backslashes}{backslashes}\\{text}"
|
|
|
|
markup = _escape(escape_backslashes, markup)
|
|
return markup
|
|
|
|
|
|
def _parse(markup: str) -> Iterable[Tuple[int, Optional[str], Optional[Tag]]]:
|
|
"""Parse markup in to an iterable of tuples of (position, text, tag).
|
|
|
|
Args:
|
|
markup (str): A string containing console markup
|
|
|
|
"""
|
|
position = 0
|
|
_divmod = divmod
|
|
_Tag = Tag
|
|
for match in RE_TAGS.finditer(markup):
|
|
full_text, escapes, tag_text = match.groups()
|
|
start, end = match.span()
|
|
if start > position:
|
|
yield start, markup[position:start], None
|
|
if escapes:
|
|
backslashes, escaped = _divmod(len(escapes), 2)
|
|
if backslashes:
|
|
# Literal backslashes
|
|
yield start, "\\" * backslashes, None
|
|
start += backslashes * 2
|
|
if escaped:
|
|
# Escape of tag
|
|
yield start, full_text[len(escapes) :], None
|
|
position = end
|
|
continue
|
|
text, equals, parameters = tag_text.partition("=")
|
|
yield start, None, _Tag(text, parameters if equals else None)
|
|
position = end
|
|
if position < len(markup):
|
|
yield position, markup[position:], None
|
|
|
|
|
|
def render(markup: str, style: Union[str, Style] = "", emoji: bool = True) -> Text:
|
|
"""Render console markup in to a Text instance.
|
|
|
|
Args:
|
|
markup (str): A string containing console markup.
|
|
emoji (bool, optional): Also render emoji code. Defaults to True.
|
|
|
|
Raises:
|
|
MarkupError: If there is a syntax error in the markup.
|
|
|
|
Returns:
|
|
Text: A test instance.
|
|
"""
|
|
emoji_replace = _emoji_replace
|
|
if "[" not in markup:
|
|
return Text(emoji_replace(markup) if emoji else markup, style=style)
|
|
text = Text(style=style)
|
|
append = text.append
|
|
normalize = Style.normalize
|
|
|
|
style_stack: List[Tuple[int, Tag]] = []
|
|
pop = style_stack.pop
|
|
|
|
spans: List[Span] = []
|
|
append_span = spans.append
|
|
|
|
_Span = Span
|
|
_Tag = Tag
|
|
|
|
def pop_style(style_name: str) -> Tuple[int, Tag]:
|
|
"""Pop tag matching given style name."""
|
|
for index, (_, tag) in enumerate(reversed(style_stack), 1):
|
|
if tag.name == style_name:
|
|
return pop(-index)
|
|
raise KeyError(style_name)
|
|
|
|
for position, plain_text, tag in _parse(markup):
|
|
if plain_text is not None:
|
|
append(emoji_replace(plain_text) if emoji else plain_text)
|
|
elif tag is not None:
|
|
if tag.name.startswith("/"): # Closing tag
|
|
style_name = tag.name[1:].strip()
|
|
if style_name: # explicit close
|
|
style_name = normalize(style_name)
|
|
try:
|
|
start, open_tag = pop_style(style_name)
|
|
except KeyError:
|
|
raise MarkupError(
|
|
f"closing tag '{tag.markup}' at position {position} doesn't match any open tag"
|
|
) from None
|
|
else: # implicit close
|
|
try:
|
|
start, open_tag = pop()
|
|
except IndexError:
|
|
raise MarkupError(
|
|
f"closing tag '[/]' at position {position} has nothing to close"
|
|
) from None
|
|
|
|
append_span(_Span(start, len(text), str(open_tag)))
|
|
else: # Opening tag
|
|
normalized_tag = _Tag(normalize(tag.name), tag.parameters)
|
|
style_stack.append((len(text), normalized_tag))
|
|
|
|
text_length = len(text)
|
|
while style_stack:
|
|
start, tag = style_stack.pop()
|
|
style = str(tag)
|
|
if style:
|
|
append_span(_Span(start, text_length, style))
|
|
|
|
text.spans = sorted(spans)
|
|
return text
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
|
|
from rich.console import Console
|
|
from rich.text import Text
|
|
|
|
console = Console(highlight=False)
|
|
|
|
console.print("Hello [1], [1,2,3] ['hello']")
|
|
console.print("foo")
|
|
console.print("Hello [link=https://www.willmcgugan.com]W[b red]o[/]rld[/]!")
|
|
|
|
from rich import print
|
|
|
|
print(escape("[red]"))
|
|
print(escape(r"\[red]"))
|
|
print(escape(r"\\[red]"))
|
|
print(escape(r"\\\[red]"))
|