Coverage for yuio / doc.py: 97%
650 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
8"""
9Utilities for parsing and formatting documentation.
11.. autoclass:: Formatter
12 :members:
14.. autoclass:: DocParser
15 :members:
18AST
19---
21.. autoclass:: AstBase
22 :members:
24.. autoclass:: Raw
25 :members:
27.. autoclass:: Text
28 :members:
30.. autoclass:: TextRegion
31 :members:
33.. autoclass:: Container
34 :members:
36.. autoclass:: Document
37 :members:
39.. autoclass:: ThematicBreak
40 :members:
42.. autoclass:: Heading
43 :members:
45.. autoclass:: Paragraph
46 :members:
48.. autoclass:: Quote
49 :members:
51.. autoclass:: Admonition
52 :members:
54.. autoclass:: Footnote
55 :members:
57.. autoclass:: FootnoteContainer
58 :members:
60.. autoclass:: Code
61 :members:
63.. autoclass:: ListEnumeratorKind
64 :members:
66.. autoclass:: ListMarkerKind
67 :members:
69.. autoclass:: ListItem
70 :members:
72.. autoclass:: List
73 :members:
75.. autoclass:: NoHeadings
76 :members:
78.. autoclass:: Cut
79 :members:
82Helpers
83-------
85.. autofunction:: to_roman
87.. autofunction:: from_roman
89.. autofunction:: to_letters
91.. autofunction:: from_letters
93"""
95from __future__ import annotations
97import abc
98import contextlib
99import dataclasses
100import re
101from dataclasses import dataclass
102from enum import Enum
104import yuio.color
105import yuio.hl
106import yuio.string
108from typing import TYPE_CHECKING
110if TYPE_CHECKING:
111 import typing_extensions as _t
112else:
113 from yuio import _typing as _t
115__all__ = [
116 "Admonition",
117 "AstBase",
118 "Code",
119 "Container",
120 "Cut",
121 "DecorationRegion",
122 "DocParser",
123 "Document",
124 "Footnote",
125 "FootnoteContainer",
126 "Formatter",
127 "Heading",
128 "HighlightedRegion",
129 "LinkRegion",
130 "List",
131 "ListEnumeratorKind",
132 "ListItem",
133 "ListMarkerKind",
134 "NoHeadings",
135 "NoWrapRegion",
136 "Paragraph",
137 "Quote",
138 "Raw",
139 "Text",
140 "TextRegion",
141 "ThematicBreak",
142 "from_letters",
143 "from_roman",
144 "to_letters",
145 "to_roman",
146]
149class DocParser(abc.ABC):
150 """
151 Base class for document parsers.
153 """
155 @abc.abstractmethod
156 def parse(self, s: str, /) -> Document:
157 """
158 Parse the given document and return its AST structure.
160 :param s:
161 document to parse.
162 :returns:
163 document AST.
165 """
167 raise NotImplementedError()
169 @abc.abstractmethod
170 def parse_paragraph(self, s: str, /) -> list[str | TextRegion]:
171 """
172 Parse inline markup in the given paragraph.
174 :param s:
175 paragraph to parse.
176 :returns:
177 inline AST.
179 """
181 raise NotImplementedError()
184@_t.final
185class Formatter:
186 """
187 A formatter suitable for displaying RST/Markdown documents in the terminal.
189 :param ctx:
190 a :class:`~yuio.string.ReprContext` that's used to colorize or wrap
191 rendered document.
192 :param allow_headings:
193 if set to :data:`False`, headings are rendered as paragraphs.
195 """
197 def __init__(
198 self,
199 ctx: yuio.string.ReprContext,
200 *,
201 allow_headings: bool = True,
202 ):
203 self._ctx = ctx
204 self._allow_headings: bool = allow_headings
205 self._is_first_line: bool
206 self._out: list[yuio.string.ColorizedString]
207 self._indent: yuio.string.ColorizedString
208 self._continuation_indent: yuio.string.ColorizedString
209 self._colors: list[yuio.color.Color]
211 @property
212 def ctx(self):
213 return self._ctx
215 @property
216 def width(self):
217 return self._ctx.width
219 def format(self, node: AstBase, /) -> list[yuio.string.ColorizedString]:
220 """
221 Format a parsed document.
223 :param node:
224 AST node to format.
225 :returns:
226 rendered document as a list of individual lines without newline
227 characters at the end.
229 """
231 self._is_first_line = True
232 self._separate_paragraphs = True
233 self._out = []
234 self._indent = yuio.string.ColorizedString()
235 self._continuation_indent = yuio.string.ColorizedString()
236 self._colors = []
238 self._format(node)
240 return self._out
242 @contextlib.contextmanager
243 def _with_color(self, color: yuio.color.Color | str | None):
244 color = self.ctx.to_color(color)
245 if self._colors:
246 color = self._colors[-1] | color
247 self._colors.append(color)
248 try:
249 yield
250 finally:
251 self._colors.pop()
253 def _text_color(self) -> yuio.color.Color:
254 if self._colors:
255 return self._colors[-1]
256 else:
257 return yuio.color.Color.NONE
259 @contextlib.contextmanager
260 def _with_indent(
261 self,
262 color: yuio.color.Color | str | None,
263 s: yuio.string.AnyString,
264 /,
265 *,
266 continue_with_spaces: bool = True,
267 ):
268 color = self.ctx.to_color(color)
269 indent = yuio.string.ColorizedString(color)
270 indent += s
272 old_indent = self._indent
273 old_continuation_indent = self._continuation_indent
275 if continue_with_spaces:
276 continuation_indent = yuio.string.ColorizedString(" " * indent.width)
277 else:
278 continuation_indent = indent
280 self._indent = first_line_indent = self._indent + indent
281 self._continuation_indent = self._continuation_indent + continuation_indent
283 try:
284 yield
285 finally:
286 if self._indent == first_line_indent:
287 self._indent = old_indent
288 else:
289 self._indent = old_continuation_indent
290 self._continuation_indent = old_continuation_indent
292 def _line(self, line: yuio.string.ColorizedString, /):
293 self._out.append(line)
295 self._is_first_line = False
296 self._indent = self._continuation_indent
298 def _format(self, node: AstBase, /):
299 getattr(self, f"_format_{node.__class__.__name__.lstrip('_')}")(node)
301 def _format_Raw(self, node: Raw, /):
302 for line in node.raw.with_base_color(self._text_color()).wrap(
303 self.width,
304 indent=self._indent,
305 continuation_indent=self._continuation_indent,
306 break_long_nowrap_words=True,
307 ):
308 self._line(line)
310 def _format_Text(self, node: Text, /):
311 text = self._format_inline(node.items, default_color=self._text_color())
312 for line in text.wrap(
313 self.width,
314 indent=self._indent,
315 continuation_indent=self._continuation_indent,
316 preserve_newlines=False,
317 break_long_nowrap_words=True,
318 ):
319 self._line(line)
321 def _format_Container(self, node: Container[TAst], /, *, allow_empty: bool = False):
322 self._is_first_line = True
323 items = node.items
324 if not items and not allow_empty:
325 items = [Paragraph(items=[""])]
326 for item in items:
327 if not self._is_first_line and self._separate_paragraphs:
328 self._line(self._indent)
329 self._format(item)
331 def _format_Document(self, node: Document, /):
332 self._format_Container(node, allow_empty=True)
334 def _format_ThematicBreak(self, _: ThematicBreak):
335 decoration = self.ctx.get_msg_decoration("thematic_break")
336 color = self.ctx.get_color("msg/decoration:thematic_break")
337 self._line(self._indent + color + decoration)
339 def _format_Heading(self, node: Heading, /):
340 if not self._allow_headings:
341 with self._with_color("msg/text:paragraph"):
342 self._format_Text(node)
343 return
345 if not self._is_first_line:
346 self._line(self._indent)
348 level = node.level
349 decoration = self.ctx.get_msg_decoration(f"heading/{level}")
350 with (
351 self._with_indent(f"msg/decoration:heading/{level}", decoration),
352 self._with_color(f"msg/text:heading/{level}"),
353 ):
354 self._format_Text(node)
356 self._line(self._indent)
357 self._is_first_line = True
359 def _format_Paragraph(self, node: Paragraph, /):
360 with self._with_color("msg/text:paragraph"):
361 self._format_Text(node)
363 def _format_ListItem(
364 self,
365 node: ListItem,
366 /,
367 *,
368 marker: str | None = None,
369 max_marker_width: int = 0,
370 ):
371 decoration = self.ctx.get_msg_decoration("list")
372 if marker:
373 max_marker_width = max(max_marker_width, yuio.string.line_width(decoration))
374 decoration = f"{marker:<{max_marker_width}}"
375 if not node.items:
376 node.items = [Paragraph(items=[])]
377 with (
378 self._with_indent("msg/decoration:list", decoration),
379 self._with_color("msg/text:list"),
380 ):
381 self._format_Container(node)
383 def _format_Quote(self, node: Quote, /):
384 decoration = self.ctx.get_msg_decoration("quote")
385 with (
386 self._with_indent(
387 "msg/decoration:quote", decoration, continue_with_spaces=False
388 ),
389 self._with_color("msg/text:quote"),
390 ):
391 self._format_Container(node)
393 def _format_Admonition(self, node: Admonition, /):
394 if node.title:
395 decoration = self.ctx.get_msg_decoration("admonition/title")
396 with self._with_indent(
397 f"msg/decoration:admonition/title/{node.type}",
398 decoration,
399 continue_with_spaces=False,
400 ):
401 title = self._format_inline(
402 node.title,
403 default_color=self.ctx.get_color(
404 f"msg/text:admonition/title/{node.type}"
405 ),
406 )
407 for line in title.wrap(
408 self.width,
409 indent=self._indent,
410 continuation_indent=self._continuation_indent,
411 preserve_newlines=False,
412 break_long_nowrap_words=True,
413 ):
414 self._line(line)
415 if node.items:
416 decoration = self.ctx.get_msg_decoration("admonition/body")
417 with (
418 self._with_indent(
419 f"msg/decoration:admonition/body/{node.type}",
420 decoration,
421 continue_with_spaces=False,
422 ),
423 self._with_color(f"msg/text:admonition/body/{node.type}"),
424 ):
425 self._format_Container(node)
427 def _format_Footnote(self, node: Footnote, /):
428 if yuio.string.line_width(node.marker) > 2:
429 indent = " "
430 self._line(self._indent + self.ctx.get_color("role/footnote") + node.marker)
431 else:
432 indent = f"{node.marker!s:4}"
433 with (
434 self._with_indent("msg/decoration:footnote", indent),
435 self._with_color("msg/text:footnote"),
436 ):
437 self._format_Container(node)
439 def _format_FootnoteContainer(self, node: FootnoteContainer, /):
440 if not node.items:
441 return
443 prev_separate_paragraphs = self._separate_paragraphs
444 self._separate_paragraphs = False
445 try:
446 self._format_ThematicBreak(ThematicBreak())
447 self._format_Container(node)
448 finally:
449 self._separate_paragraphs = prev_separate_paragraphs
451 def _format_Code(self, node: Code, /):
452 if not node.lines:
453 return
455 highlighter, syntax_name = yuio.hl.get_highlighter(node.syntax)
456 s = highlighter.highlight(
457 "\n".join(node.lines),
458 theme=self.ctx.theme,
459 syntax=syntax_name,
460 default_color=self._text_color(),
461 )
463 decoration = self.ctx.get_msg_decoration("code")
464 with self._with_indent("msg/decoration:code", decoration):
465 self._line(
466 s.indent(
467 indent=self._indent,
468 continuation_indent=self._continuation_indent,
469 )
470 )
472 def _format_List(self, node: List, /):
473 if not node.items:
474 return
476 match node.enumerator_kind:
477 case ListEnumeratorKind.NUMBER:
478 format_marker = lambda c: f"{c}."
479 case ListEnumeratorKind.SMALL_LETTER:
480 format_marker = lambda c: f"{to_letters(c)}."
481 case ListEnumeratorKind.CAPITAL_LETTER:
482 format_marker = lambda c: f"{to_letters(c).upper()}."
483 case ListEnumeratorKind.SMALL_ROMAN:
484 format_marker = lambda c: f"{to_roman(c)}."
485 case ListEnumeratorKind.CAPITAL_ROMAN:
486 format_marker = lambda c: f"{to_roman(c).upper()}."
487 case _:
488 format_marker = None
490 n = node.items[0].number
492 if n and format_marker:
493 formatted_markers = [format_marker(n + i) for i in range(len(node.items))]
494 max_marker_width = (
495 max(yuio.string.line_width(marker) for marker in formatted_markers) + 1
496 )
497 else:
498 formatted_markers = [None] * len(node.items)
499 max_marker_width = 0
501 self._is_first_line = True
502 separate_paragraphs = self._separate_paragraphs
503 if all(
504 not item.items
505 or (len(item.items) == 1 and isinstance(item.items[0], Paragraph))
506 for item in node.items
507 ):
508 separate_paragraphs = False
509 for item, marker in zip(node.items, formatted_markers):
510 if not self._is_first_line and separate_paragraphs:
511 self._line(self._indent)
512 self._format_ListItem(
513 item, marker=marker, max_marker_width=max_marker_width
514 )
516 def _format_NoHeadings(self, node: NoHeadings, /):
517 prev_allow_headings = self._allow_headings
518 self._allow_headings = False
519 try:
520 self._format_Container(node)
521 finally:
522 self._allow_headings = prev_allow_headings
524 def _format_inline(
525 self,
526 items: _t.Sequence[str | TextRegion],
527 /,
528 *,
529 default_color: yuio.color.Color,
530 ):
531 s = yuio.string.ColorizedString()
533 for item in items:
534 if isinstance(item, str):
535 s.append_color(default_color)
536 s.append_str(item)
537 else:
538 s += getattr(
539 self, f"_format_inline_{item.__class__.__name__.lstrip('_')}"
540 )(item, default_color=default_color)
542 return s
544 def _format_inline_TextRegion(
545 self, node: TextRegion, /, *, default_color: yuio.color.Color
546 ):
547 return self._format_inline(node.content, default_color=default_color)
549 def _format_inline_HighlightedRegion(
550 self, node: HighlightedRegion, /, *, default_color: yuio.color.Color
551 ):
552 if node.color:
553 default_color |= self.ctx.get_color(node.color)
554 return self._format_inline(node.content, default_color=default_color)
556 def _format_inline_NoWrapRegion(
557 self, node: NoWrapRegion, /, *, default_color: yuio.color.Color
558 ):
559 s = yuio.string.ColorizedString()
560 s.start_no_wrap()
561 s += self._format_inline(node.content, default_color=default_color)
562 s.end_no_wrap()
563 return s
565 def _format_inline_LinkRegion(
566 self, node: LinkRegion, /, *, default_color: yuio.color.Color
567 ):
568 s = yuio.string.ColorizedString()
569 if node.url:
570 s.start_link(node.url)
571 s += self._format_inline(node.content, default_color=default_color)
572 if node.url:
573 if not self.ctx.term.supports_colors:
574 s.append_color(default_color)
575 s.append_str(f" [{node.url}]")
576 s.end_link()
577 return s
579 def _format_inline_DecorationRegion(
580 self, node: DecorationRegion, /, *, default_color: yuio.color.Color
581 ):
582 return yuio.string.ColorizedString(
583 default_color, self.ctx.get_msg_decoration(node.decoration_path)
584 )
587TAst = _t.TypeVar("TAst", bound="AstBase")
590@dataclass(kw_only=True, slots=True)
591class AstBase:
592 """
593 Base class for all AST nodes that represent parsed Markdown and RST documents.
595 """
597 def _dump_params(self) -> str:
598 s = self.__class__.__name__.lstrip("_")
599 for field in dataclasses.fields(self):
600 if field.repr:
601 s += f" {getattr(self, field.name)!r}"
602 return s
604 def dump(self, indent: str = "") -> str:
605 """
606 Dump an AST node into a lisp-like text representation.
608 """
610 return f"{indent}({self._dump_params()})"
613@dataclass(kw_only=True, slots=True)
614class Raw(AstBase):
615 """
616 Embeds already formatted paragraph into the document.
618 """
620 raw: yuio.string.ColorizedString
621 """
622 Raw colorized string to add to the document.
624 """
627@dataclass(kw_only=True, slots=True)
628class Text(AstBase):
629 """
630 Base class for all text-based AST nodes, i.e. paragraphs, headings, etc.
632 """
634 items: list[str | TextRegion] = dataclasses.field(repr=False)
635 """
636 Text lines as parsed from the original document.
638 """
640 def dump(self, indent: str = "") -> str:
641 s = f"{indent}({self._dump_params()}"
642 indent += " "
643 for line in self.items:
644 s += "\n" + indent
645 s += repr(line)
646 s += ")"
647 return s
650@dataclass(kw_only=True, slots=True)
651class TextRegion:
652 """
653 Text region with special formatting.
655 """
657 content: list[str | TextRegion]
658 """
659 Region contents.
661 """
663 def __init__(self, *args: str | TextRegion):
664 self.content = list(args)
667@dataclass(kw_only=True, slots=True)
668class HighlightedRegion(TextRegion):
669 """
670 Highlighted text region.
672 """
674 color: str
675 """
676 Color path to be applied to the region's contents.
678 """
680 def __init__(self, *args: str | TextRegion, color: str):
681 self.content = list(args)
682 self.color = color
685@dataclass(kw_only=True, slots=True)
686class DecorationRegion(TextRegion):
687 """
688 Inserts a single decoration from current theme.
690 """
692 decoration_path: str
693 """
694 Decoration path.
696 """
698 def __init__(self, decoration_path: str):
699 self.content = []
700 self.decoration_path = decoration_path
703@dataclass(kw_only=True, slots=True)
704class NoWrapRegion(TextRegion):
705 """
706 Text region with disabled line wrapping.
708 """
710 def __init__(self, *args: str | TextRegion):
711 self.content = list(args)
714@dataclass(kw_only=True, slots=True)
715class LinkRegion(TextRegion):
716 """
717 Text region with a link.
719 """
721 url: str
722 """
723 Makes this region into a hyperlink.
725 """
727 def __init__(self, *args: str | TextRegion, url: str):
728 self.content = list(args)
729 self.url = url
732@dataclass(kw_only=True, slots=True)
733class Container(AstBase, _t.Generic[TAst]):
734 """
735 Base class for all container-based AST nodes, i.e. list items or quotes.
737 This class works as a list of items. Usually it contains arbitrary AST nodes,
738 but it can also be limited to specific kinds of nodes via its generic variable.
740 """
742 items: list[TAst] = dataclasses.field(repr=False)
743 """
744 Inner AST nodes in the container.
746 """
748 def dump(self, indent: str = "") -> str:
749 s = f"{indent or ''}({self._dump_params()}"
750 indent += " "
751 for items in self.items:
752 s += "\n"
753 s += items.dump(indent)
754 s += ")"
755 return s
758@dataclass(kw_only=True, slots=True)
759class Document(Container[AstBase]):
760 """
761 Root node that contains the entire document.
763 """
766@dataclass(kw_only=True, slots=True)
767class ThematicBreak(AstBase):
768 """
769 Represents a visual break in text, a.k.a. an asterism.
771 """
774@dataclass(kw_only=True, slots=True)
775class Heading(Text):
776 """
777 Represents a heading.
779 """
781 level: int
782 """
783 Level of the heading, `1`-based.
785 """
788@dataclass(kw_only=True, slots=True)
789class Paragraph(Text):
790 """
791 Represents a regular paragraph.
793 """
796@dataclass(kw_only=True, slots=True)
797class Quote(Container[AstBase]):
798 """
799 Represents a quotation block.
801 """
804@dataclass(kw_only=True, slots=True)
805class Admonition(Container[AstBase]):
806 """
807 Represents an admonition block.
809 """
811 title: list[str | TextRegion] = dataclasses.field(repr=False)
812 """
813 Main title.
815 """
817 type: str
818 """
819 Admonition type.
821 """
823 def dump(self, indent: str = "") -> str:
824 s = f"{indent}({self._dump_params()}\n{indent} (title"
825 indent += " "
826 for line in self.title:
827 s += "\n " + indent
828 s += repr(line)
829 s += ")"
830 for items in self.items:
831 s += "\n"
832 s += items.dump(indent)
833 s += ")"
834 return s
837@dataclass(kw_only=True, slots=True)
838class Footnote(Container[AstBase]):
839 """
840 Represents a footnote.
842 """
844 marker: str
845 """
846 Footnote number or marker.
848 """
851@dataclass(eq=False, match_args=False, slots=True)
852class FootnoteContainer(Container[Footnote]):
853 """
854 Container for footnotes, enables compact rendering.
856 """
859@dataclass(kw_only=True, slots=True)
860class Code(AstBase):
861 """
862 Represents a highlighted block of code.
864 """
866 lines: list[str] = dataclasses.field(repr=False)
867 """
868 Code lines as parsed from the original document.
870 """
872 syntax: str
873 """
874 Syntax indicator as parsed form the original document.
876 """
878 def dump(self, indent: str = "") -> str:
879 s = f"{indent}({self._dump_params()}"
880 indent += " "
881 for line in self.lines:
882 s += "\n" + indent
883 s += repr(line)
884 s += ")"
885 return s
888class ListEnumeratorKind(Enum):
889 """
890 For enumerated lists, represents how numbers should look.
892 """
894 NUMBER = "NUMBER"
895 """
896 Numeric, i.e. ``1, 2, 3``.
897 """
899 SMALL_LETTER = "SMALL_LETTER"
900 """
901 Small letters, i.e. ``a, b, c``.
902 """
904 CAPITAL_LETTER = "CAPITAL_LETTER"
905 """
906 Capital letters, i.e. ``A, B, C``.
907 """
909 SMALL_ROMAN = "SMALL_ROMAN"
910 """
911 Small roman numerals, i.e. ``i, ii, iii``.
912 """
914 CAPITAL_ROMAN = "CAPITAL_ROMAN"
915 """
916 Capital roman numerals, i.e. ``I, II, III``.
917 """
920class ListMarkerKind(Enum):
921 """
922 For enumerated lists, represents how numbers are stylized.
924 """
926 DOT = "DOT"
927 """
928 Dot after a number, i.e. ``1.``.
930 """
932 PAREN = "PAREN"
933 """
934 Paren after a number, i.e. ``1)``.
936 """
938 ENCLOSED = "ENCLOSED"
939 """
940 Parens around a number, i.e. ``(1)``.
942 """
945@dataclass(kw_only=True, slots=True)
946class ListItem(Container[AstBase]):
947 """
948 A possibly numbered element of a list.
950 """
952 number: int | None
953 """
954 If present, this is the item's number in a numbered list.
956 """
959@dataclass(kw_only=True, slots=True)
960class List(Container[ListItem]):
961 """
962 A collection of list items.
964 """
966 enumerator_kind: ListEnumeratorKind | str | None = None
967 """
968 Enumerator kind for numbered lists, or symbol for bullet lists.
970 """
972 marker_kind: ListMarkerKind | None = None
973 """
974 Marker kind for numbered lists.
976 """
979@dataclass(kw_only=True, slots=True)
980class NoHeadings(Container[AstBase]):
981 """
982 Suppresses headings rendering for its children.
984 """
987@dataclass(kw_only=True, slots=True)
988class Cut(AstBase):
989 """
990 Stops rendering of the container.
992 """
995_ROMAN_VALUES = {
996 "m": 1000,
997 "cm": 900,
998 "d": 500,
999 "cd": 400,
1000 "c": 100,
1001 "xc": 90,
1002 "l": 50,
1003 "xl": 40,
1004 "x": 10,
1005 "ix": 9,
1006 "v": 5,
1007 "iv": 4,
1008 "i": 1,
1009}
1012def to_roman(n: int, /) -> str:
1013 """
1014 Convert positive integer to lower-case roman numeral.
1016 """
1018 assert n > 0
1020 result = ""
1021 for numeral, integer in _ROMAN_VALUES.items():
1022 while n >= integer:
1023 result += numeral
1024 n -= integer
1025 return result
1028def from_roman(s: str, /) -> int | None:
1029 """
1030 Parse roman numeral, return :data:`None` if parsing fails.
1032 """
1034 total = 0
1035 prev_value = 0
1036 for c in reversed(s.casefold()):
1037 value = _ROMAN_VALUES.get(c, 0)
1038 if not value:
1039 return None
1040 if value < prev_value:
1041 # If current value is less than previous, subtract it (e.g. IV = 4).
1042 total -= value
1043 else:
1044 total += value
1045 prev_value = value
1046 return total
1049def to_letters(n: int, /) -> str:
1050 """
1051 Convert positive integer to lowercase excel-column-like letter numeral.
1053 """
1055 assert n > 0
1057 result = ""
1058 while n > 0:
1059 n -= 1
1060 result = chr(ord("a") + n % 26) + result
1061 n //= 26
1063 return result
1066def from_letters(s: str, /):
1067 """
1068 Parse excel-column-like letter numeral, return :data:`None` if parsing fails.
1070 """
1072 if not s.isalpha():
1073 return None
1075 s = s.casefold()
1076 result = 0
1078 for char in s:
1079 result = result * 26 + (ord(char) - ord("a") + 1)
1081 return result
1084_DirectiveHandler: _t.TypeAlias = _t.Callable[
1085 [str, str, _t.Callable[[], list[str]], _t.Callable[[], list[AstBase]]],
1086 _t.Sequence[AstBase],
1087]
1089_KNOWN_DIRECTIVES: dict[str, _DirectiveHandler] = {}
1092def _process_directive(
1093 name: str,
1094 arg: str,
1095 get_lines: _t.Callable[[], list[str]],
1096 get_parsed: _t.Callable[[], list[AstBase]],
1097) -> _t.Sequence[AstBase]:
1098 if name in _KNOWN_DIRECTIVES:
1099 return _KNOWN_DIRECTIVES[name](name, arg, get_lines, get_parsed)
1100 else:
1101 return [
1102 Admonition(
1103 items=get_parsed(), title=[f".. {name}:: {arg}"], type="unknown-dir"
1104 )
1105 ]
1108def _directive(names: list[str]) -> _t.Callable[[_DirectiveHandler], _DirectiveHandler]:
1109 def _registrar(fn):
1110 for name in names:
1111 _KNOWN_DIRECTIVES[name] = fn
1112 return fn
1114 return _registrar
1117@_directive(["code-block", "sourcecode", "code"])
1118def _process_code_directive(name, arg, get_lines, get_parsed):
1119 return [Code(lines=get_lines(), syntax=arg)]
1122@_directive(
1123 [
1124 "attention",
1125 "caution",
1126 "danger",
1127 "error",
1128 "hint",
1129 "important",
1130 "note",
1131 "seealso",
1132 "tip",
1133 "warning",
1134 ]
1135)
1136def _process_admonition_directive(name, arg, get_lines, get_parsed):
1137 return [Admonition(title=[name.title()], items=get_parsed(), type=name)]
1140@_directive(["admonition"])
1141def _process_custom_admonition_directive(name, arg, get_lines, get_parsed):
1142 return [Admonition(title=[arg], items=get_parsed(), type=name)]
1145@_directive(
1146 [
1147 "versionadded",
1148 "versionchanged",
1149 "deprecated",
1150 ]
1151)
1152def _process_version_directive(name, arg, get_lines, get_parsed):
1153 return [
1154 Admonition(
1155 title=[
1156 name.removeprefix("version").title(),
1157 " in version ",
1158 arg,
1159 ],
1160 items=get_parsed(),
1161 type=name,
1162 )
1163 ]
1166@_directive(["if-not-sphinx", "if-opt-doc"])
1167def _process_id_directive(name, arg, get_lines, get_parsed):
1168 return get_parsed()
1171@_directive(["if-sphinx", "if-not-opt-doc"])
1172def _process_nop_directive(name, arg, get_lines, get_parsed):
1173 return []
1176@_directive(["cut-if-not-sphinx"])
1177def _process_cut_directive(name, arg, get_lines, get_parsed):
1178 return [Cut()]
1181_CROSSREF_RE = re.compile(
1182 r"""
1183 ^
1184 (?P<title>(?:[^\\]|\\.)*?)
1185 (?:(?<!^)\s*<(?P<target>.*)>)?
1186 $
1187 """,
1188 re.VERBOSE,
1189)
1192_RoleHandler: _t.TypeAlias = _t.Callable[[str, str], TextRegion]
1194_KNOWN_ROLES: dict[str, _RoleHandler] = {}
1197def _role(names: list[str]) -> _t.Callable[[_RoleHandler], _RoleHandler]:
1198 def _registrar(fn):
1199 for name in names:
1200 _KNOWN_ROLES[name] = fn
1201 return fn
1203 return _registrar
1206def _process_role(text: str, role: str) -> TextRegion:
1207 if not role:
1208 role = "default"
1210 if role in _KNOWN_ROLES:
1211 return _KNOWN_ROLES[role](role, text)
1212 else:
1213 # Assume generic reference role by default.
1214 role = role.replace(":", "/")
1215 return NoWrapRegion(
1216 HighlightedRegion(_process_ref(text), color=f"role/unknown/{role}")
1217 )
1220def _process_ref(
1221 text: str, parse_path=None, join_path=None, refspecific_marker: str = "."
1222):
1223 if parse_path is None:
1224 parse_path = lambda s: s.split(".")
1225 if join_path is None:
1226 join_path = lambda p: ".".join(p)
1228 if text.startswith("!"):
1229 text = text[1:]
1231 match = _CROSSREF_RE.match(text)
1232 if not match: # pragma: no cover
1233 return text
1235 title = match.group("title")
1236 target = match.group("target")
1238 # Sphinx unescapes role contents.
1239 title = re.sub(r"\\(?:\s|(.))", r"\1", title)
1241 if not target:
1242 # Implicit title.def _unescape(text: str) -> str:
1243 if title.startswith("~"):
1244 title = parse_path(title[1:])[-1]
1245 else:
1246 title = join_path(parse_path(title.removeprefix(refspecific_marker)))
1247 else:
1248 title = title.rstrip()
1250 return title
1253@_role(
1254 [
1255 "flag",
1256 "code",
1257 "literal",
1258 "math",
1259 "abbr",
1260 "command",
1261 "dfn",
1262 "mailheader",
1263 "makevar",
1264 "mimetype",
1265 "newsgroup",
1266 "program",
1267 "regexp",
1268 "cve",
1269 "cwe",
1270 "pep",
1271 "rfc",
1272 "manpage",
1273 "kbd",
1274 ]
1275)
1276def _process_simple_role(name: str, text: str):
1277 return NoWrapRegion(HighlightedRegion(text, color=f"role/{name}"))
1280@_role(
1281 [
1282 "any",
1283 "doc",
1284 "download",
1285 "envvar",
1286 "keyword",
1287 "numref",
1288 "option",
1289 "cmdoption",
1290 "ref",
1291 "term",
1292 "token",
1293 "eq",
1294 ]
1295)
1296def _process_ref_role(name: str, text: str):
1297 return NoWrapRegion(HighlightedRegion(_process_ref(text), color=f"role/{name}"))
1300@_role(
1301 [
1302 "cli:cfg",
1303 "cli:field",
1304 "cli:obj",
1305 "cli:env",
1306 "cli:any",
1307 ]
1308)
1309def _process_cli_cfg_role(name: str, text: str):
1310 name = name.replace(":", "/")
1311 return NoWrapRegion(
1312 HighlightedRegion(
1313 _process_ref(text, _parse_cfg_path, ".".join), color=f"role/{name}"
1314 )
1315 )
1318@_role(
1319 [
1320 "cli:cmd",
1321 "cli:flag",
1322 "cli:arg",
1323 "cli:opt",
1324 "cli:cli",
1325 ]
1326)
1327def _process_cli_cmd_role(name: str, text: str):
1328 name = name.replace(":", "/")
1329 return NoWrapRegion(
1330 HighlightedRegion(
1331 _process_ref(text, _parse_cmd_path, " ".join, refspecific_marker=". "),
1332 color=f"role/{name}",
1333 )
1334 )
1337@_role(["guilabel"])
1338def _process_gui_label_role(name: str, text: str):
1339 spans = re.split(r"(?<!&)&(?![&\s])", text)
1341 res = NoWrapRegion()
1342 if start := spans.pop(0):
1343 res.content.append(HighlightedRegion(start, color=f"role/{name}"))
1345 for span in spans:
1346 span = span.replace("&&", "&")
1347 if span[0]:
1348 res.content.append(
1349 HighlightedRegion(span[0], color=f"role/{name}/accelerator")
1350 )
1351 if span[1:]:
1352 res.content.append(HighlightedRegion(span[1:], color=f"role/{name}"))
1354 return res
1357@_role(["menuselection"])
1358def _process_menuselection_role(name: str, text: str):
1359 res = NoWrapRegion()
1361 for region in _process_gui_label_role(name, text).content:
1362 if not isinstance(region, HighlightedRegion): # pragma: no cover
1363 res.content.append(region)
1364 continue
1365 if len(region.content) != 1: # pragma: no cover
1366 res.content.append(region)
1367 continue
1368 if not isinstance(region.content[0], str): # pragma: no cover
1369 res.content.append(region)
1370 continue
1371 if "-->" not in region.content[0]:
1372 res.content.append(region)
1373 continue
1375 for part in re.split(r"\s*(-->)\s*", region.content[0]):
1376 if part == "-->":
1377 res.content.append(
1378 HighlightedRegion(
1379 DecorationRegion("menuselection_separator"),
1380 color=f"role/{name}/separator",
1381 )
1382 )
1383 elif part:
1384 res.content.append(HighlightedRegion(part, color=region.color))
1385 return res
1388@_role(["file", "samp"])
1389def _process_samp_role(name: str, text: str):
1390 res = NoWrapRegion()
1392 stack = [""]
1393 for part in re.split(r"(\\\\|\\{|\\}|{|})", text):
1394 if part == "\\\\": # escaped backslash
1395 stack[-1] += "\\"
1396 elif part == "{":
1397 if len(stack) >= 2 and stack[-2] == "{": # nested
1398 stack[-1] += "{"
1399 else:
1400 # start emphasis
1401 stack.extend(("{", ""))
1402 elif part == "}":
1403 if len(stack) == 3 and stack[1] == "{" and len(stack[2]) > 0:
1404 # emphasized word found
1405 if stack[0]:
1406 res.content.append(
1407 HighlightedRegion(stack[0], color=f"role/{name}")
1408 )
1409 res.content.append(
1410 HighlightedRegion(f"{{{stack[2]}}}", color=f"role/{name}/variable")
1411 )
1412 stack = [""]
1413 else:
1414 # emphasized word not found; the rparen is not a special symbol
1415 stack.append("}")
1416 stack = ["".join(stack)]
1417 elif part == "\\{": # escaped left-brace
1418 stack[-1] += "{"
1419 elif part == "\\}": # escaped right-brace
1420 stack[-1] += "}"
1421 else: # others (containing escaped braces)
1422 stack[-1] += part
1424 if "".join(stack):
1425 # remaining is treated as Text
1426 res.content.append(HighlightedRegion("".join(stack), color=f"role/{name}"))
1428 return res
1431def _process_link(text: str):
1432 match = _CROSSREF_RE.match(text)
1433 if not match:
1434 return None, text
1435 return match.group("target"), match.group("title")
1438def _read_parenthesized_until(s: str, end_cond: _t.Callable[[str], bool]):
1439 paren_stack = []
1440 i = 0
1441 res_start = 0
1442 res: list[str] = []
1444 def push_res():
1445 nonlocal res_start
1446 res.append(s[res_start:i])
1447 res_start = i
1449 while i < len(s):
1450 match s[i]:
1451 case c if not paren_stack and end_cond(c):
1452 push_res()
1453 return "".join(res), s[i:]
1454 case c if paren_stack and c == paren_stack[-1]:
1455 paren_stack.pop()
1456 i += 1
1457 case "\\":
1458 push_res()
1459 i += 2
1460 res_start += 1
1461 case "(":
1462 paren_stack.append(")")
1463 i += 1
1464 case "[":
1465 paren_stack.append("]")
1466 i += 1
1467 case "{":
1468 paren_stack.append("}")
1469 i += 1
1470 case "<":
1471 paren_stack.append(">")
1472 i += 1
1473 case "'" | '"':
1474 end_char = s[i]
1475 i += 1
1476 while i < len(s):
1477 match s[i]:
1478 case "\\":
1479 i += 2
1480 case c if c == end_char:
1481 i += 1
1482 break
1483 case _:
1484 i += 1
1485 case _:
1486 i += 1
1488 push_res()
1489 return "".join(res), ""
1492def _parse_cfg_path(path: str) -> tuple[str, ...]:
1493 path = re.sub(r"\s+", " ", path.strip())
1494 return tuple(path.split("."))
1497def _parse_cmd_path(path: str) -> tuple[str, ...]:
1498 path = re.sub(r"\s+", " ", path.strip())
1499 res: list[str] = []
1500 while path:
1501 part, path = _read_parenthesized_until(path, lambda c: c.isspace())
1502 path = path.lstrip()
1503 res.append(part)
1504 return tuple(res)
1507def _cmd2cfg(cmd: tuple[str, ...]) -> tuple[str, ...]:
1508 return tuple(map(_cmd2cfg_part, cmd))
1511def _cmd2cfg_part(cmd: str) -> str:
1512 cmd = cmd.lstrip("-")
1513 cmd = re.sub(r"[\s-]+", r"_", cmd)
1514 cmd = re.sub(r"[^\w]", "", cmd)
1515 return cmd # noqa: RET504
1518def _clean_tree(node: AstBase):
1519 if isinstance(node, List):
1520 if not node.items:
1521 # Empty list is left as-is.
1522 return node
1524 new_nodes = []
1525 for subnode in node.items:
1526 # List was cut at this point.
1527 if len(subnode.items) == 1 and isinstance(subnode.items[0], Cut):
1528 break
1529 if (new_subnode := _clean_tree(subnode)) is not None:
1530 new_nodes.append(new_subnode)
1532 if new_nodes:
1533 node.items = new_nodes
1534 else:
1535 # List became empty because of our cutting, don't render it.
1536 return None
1537 elif isinstance(node, Container):
1538 if not node.items:
1539 # Empty container is left as-is.
1540 return node
1542 new_nodes = []
1543 for subnode in node.items:
1544 if isinstance(subnode, Cut):
1545 break
1546 if (new_subnode := _clean_tree(subnode)) is not None:
1547 new_nodes.append(new_subnode)
1549 if new_nodes:
1550 node.items = new_nodes
1551 else:
1552 # Container became empty because of our cutting, don't render it.
1553 return None
1554 return node