Coverage for yuio / doc.py: 97%

650 statements  

« 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 

7 

8""" 

9Utilities for parsing and formatting documentation. 

10 

11.. autoclass:: Formatter 

12 :members: 

13 

14.. autoclass:: DocParser 

15 :members: 

16 

17 

18AST 

19--- 

20 

21.. autoclass:: AstBase 

22 :members: 

23 

24.. autoclass:: Raw 

25 :members: 

26 

27.. autoclass:: Text 

28 :members: 

29 

30.. autoclass:: TextRegion 

31 :members: 

32 

33.. autoclass:: Container 

34 :members: 

35 

36.. autoclass:: Document 

37 :members: 

38 

39.. autoclass:: ThematicBreak 

40 :members: 

41 

42.. autoclass:: Heading 

43 :members: 

44 

45.. autoclass:: Paragraph 

46 :members: 

47 

48.. autoclass:: Quote 

49 :members: 

50 

51.. autoclass:: Admonition 

52 :members: 

53 

54.. autoclass:: Footnote 

55 :members: 

56 

57.. autoclass:: FootnoteContainer 

58 :members: 

59 

60.. autoclass:: Code 

61 :members: 

62 

63.. autoclass:: ListEnumeratorKind 

64 :members: 

65 

66.. autoclass:: ListMarkerKind 

67 :members: 

68 

69.. autoclass:: ListItem 

70 :members: 

71 

72.. autoclass:: List 

73 :members: 

74 

75.. autoclass:: NoHeadings 

76 :members: 

77 

78.. autoclass:: Cut 

79 :members: 

80 

81 

82Helpers 

83------- 

84 

85.. autofunction:: to_roman 

86 

87.. autofunction:: from_roman 

88 

89.. autofunction:: to_letters 

90 

91.. autofunction:: from_letters 

92 

93""" 

94 

95from __future__ import annotations 

96 

97import abc 

98import contextlib 

99import dataclasses 

100import re 

101from dataclasses import dataclass 

102from enum import Enum 

103 

104import yuio.color 

105import yuio.hl 

106import yuio.string 

107 

108from typing import TYPE_CHECKING 

109 

110if TYPE_CHECKING: 

111 import typing_extensions as _t 

112else: 

113 from yuio import _typing as _t 

114 

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] 

147 

148 

149class DocParser(abc.ABC): 

150 """ 

151 Base class for document parsers. 

152 

153 """ 

154 

155 @abc.abstractmethod 

156 def parse(self, s: str, /) -> Document: 

157 """ 

158 Parse the given document and return its AST structure. 

159 

160 :param s: 

161 document to parse. 

162 :returns: 

163 document AST. 

164 

165 """ 

166 

167 raise NotImplementedError() 

168 

169 @abc.abstractmethod 

170 def parse_paragraph(self, s: str, /) -> list[str | TextRegion]: 

171 """ 

172 Parse inline markup in the given paragraph. 

173 

174 :param s: 

175 paragraph to parse. 

176 :returns: 

177 inline AST. 

178 

179 """ 

180 

181 raise NotImplementedError() 

182 

183 

184@_t.final 

185class Formatter: 

186 """ 

187 A formatter suitable for displaying RST/Markdown documents in the terminal. 

188 

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. 

194 

195 """ 

196 

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] 

210 

211 @property 

212 def ctx(self): 

213 return self._ctx 

214 

215 @property 

216 def width(self): 

217 return self._ctx.width 

218 

219 def format(self, node: AstBase, /) -> list[yuio.string.ColorizedString]: 

220 """ 

221 Format a parsed document. 

222 

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. 

228 

229 """ 

230 

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 = [] 

237 

238 self._format(node) 

239 

240 return self._out 

241 

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() 

252 

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 

258 

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 

271 

272 old_indent = self._indent 

273 old_continuation_indent = self._continuation_indent 

274 

275 if continue_with_spaces: 

276 continuation_indent = yuio.string.ColorizedString(" " * indent.width) 

277 else: 

278 continuation_indent = indent 

279 

280 self._indent = first_line_indent = self._indent + indent 

281 self._continuation_indent = self._continuation_indent + continuation_indent 

282 

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 

291 

292 def _line(self, line: yuio.string.ColorizedString, /): 

293 self._out.append(line) 

294 

295 self._is_first_line = False 

296 self._indent = self._continuation_indent 

297 

298 def _format(self, node: AstBase, /): 

299 getattr(self, f"_format_{node.__class__.__name__.lstrip('_')}")(node) 

300 

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) 

309 

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) 

320 

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) 

330 

331 def _format_Document(self, node: Document, /): 

332 self._format_Container(node, allow_empty=True) 

333 

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) 

338 

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 

344 

345 if not self._is_first_line: 

346 self._line(self._indent) 

347 

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) 

355 

356 self._line(self._indent) 

357 self._is_first_line = True 

358 

359 def _format_Paragraph(self, node: Paragraph, /): 

360 with self._with_color("msg/text:paragraph"): 

361 self._format_Text(node) 

362 

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) 

382 

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) 

392 

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) 

426 

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) 

438 

439 def _format_FootnoteContainer(self, node: FootnoteContainer, /): 

440 if not node.items: 

441 return 

442 

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 

450 

451 def _format_Code(self, node: Code, /): 

452 if not node.lines: 

453 return 

454 

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 ) 

462 

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 ) 

471 

472 def _format_List(self, node: List, /): 

473 if not node.items: 

474 return 

475 

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 

489 

490 n = node.items[0].number 

491 

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 

500 

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 ) 

515 

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 

523 

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() 

532 

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) 

541 

542 return s 

543 

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) 

548 

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) 

555 

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 

564 

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 

578 

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 ) 

585 

586 

587TAst = _t.TypeVar("TAst", bound="AstBase") 

588 

589 

590@dataclass(kw_only=True, slots=True) 

591class AstBase: 

592 """ 

593 Base class for all AST nodes that represent parsed Markdown and RST documents. 

594 

595 """ 

596 

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 

603 

604 def dump(self, indent: str = "") -> str: 

605 """ 

606 Dump an AST node into a lisp-like text representation. 

607 

608 """ 

609 

610 return f"{indent}({self._dump_params()})" 

611 

612 

613@dataclass(kw_only=True, slots=True) 

614class Raw(AstBase): 

615 """ 

616 Embeds already formatted paragraph into the document. 

617 

618 """ 

619 

620 raw: yuio.string.ColorizedString 

621 """ 

622 Raw colorized string to add to the document. 

623 

624 """ 

625 

626 

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. 

631 

632 """ 

633 

634 items: list[str | TextRegion] = dataclasses.field(repr=False) 

635 """ 

636 Text lines as parsed from the original document. 

637 

638 """ 

639 

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 

648 

649 

650@dataclass(kw_only=True, slots=True) 

651class TextRegion: 

652 """ 

653 Text region with special formatting. 

654 

655 """ 

656 

657 content: list[str | TextRegion] 

658 """ 

659 Region contents. 

660 

661 """ 

662 

663 def __init__(self, *args: str | TextRegion): 

664 self.content = list(args) 

665 

666 

667@dataclass(kw_only=True, slots=True) 

668class HighlightedRegion(TextRegion): 

669 """ 

670 Highlighted text region. 

671 

672 """ 

673 

674 color: str 

675 """ 

676 Color path to be applied to the region's contents. 

677 

678 """ 

679 

680 def __init__(self, *args: str | TextRegion, color: str): 

681 self.content = list(args) 

682 self.color = color 

683 

684 

685@dataclass(kw_only=True, slots=True) 

686class DecorationRegion(TextRegion): 

687 """ 

688 Inserts a single decoration from current theme. 

689 

690 """ 

691 

692 decoration_path: str 

693 """ 

694 Decoration path. 

695 

696 """ 

697 

698 def __init__(self, decoration_path: str): 

699 self.content = [] 

700 self.decoration_path = decoration_path 

701 

702 

703@dataclass(kw_only=True, slots=True) 

704class NoWrapRegion(TextRegion): 

705 """ 

706 Text region with disabled line wrapping. 

707 

708 """ 

709 

710 def __init__(self, *args: str | TextRegion): 

711 self.content = list(args) 

712 

713 

714@dataclass(kw_only=True, slots=True) 

715class LinkRegion(TextRegion): 

716 """ 

717 Text region with a link. 

718 

719 """ 

720 

721 url: str 

722 """ 

723 Makes this region into a hyperlink. 

724 

725 """ 

726 

727 def __init__(self, *args: str | TextRegion, url: str): 

728 self.content = list(args) 

729 self.url = url 

730 

731 

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. 

736 

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. 

739 

740 """ 

741 

742 items: list[TAst] = dataclasses.field(repr=False) 

743 """ 

744 Inner AST nodes in the container. 

745 

746 """ 

747 

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 

756 

757 

758@dataclass(kw_only=True, slots=True) 

759class Document(Container[AstBase]): 

760 """ 

761 Root node that contains the entire document. 

762 

763 """ 

764 

765 

766@dataclass(kw_only=True, slots=True) 

767class ThematicBreak(AstBase): 

768 """ 

769 Represents a visual break in text, a.k.a. an asterism. 

770 

771 """ 

772 

773 

774@dataclass(kw_only=True, slots=True) 

775class Heading(Text): 

776 """ 

777 Represents a heading. 

778 

779 """ 

780 

781 level: int 

782 """ 

783 Level of the heading, `1`-based. 

784 

785 """ 

786 

787 

788@dataclass(kw_only=True, slots=True) 

789class Paragraph(Text): 

790 """ 

791 Represents a regular paragraph. 

792 

793 """ 

794 

795 

796@dataclass(kw_only=True, slots=True) 

797class Quote(Container[AstBase]): 

798 """ 

799 Represents a quotation block. 

800 

801 """ 

802 

803 

804@dataclass(kw_only=True, slots=True) 

805class Admonition(Container[AstBase]): 

806 """ 

807 Represents an admonition block. 

808 

809 """ 

810 

811 title: list[str | TextRegion] = dataclasses.field(repr=False) 

812 """ 

813 Main title. 

814 

815 """ 

816 

817 type: str 

818 """ 

819 Admonition type. 

820 

821 """ 

822 

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 

835 

836 

837@dataclass(kw_only=True, slots=True) 

838class Footnote(Container[AstBase]): 

839 """ 

840 Represents a footnote. 

841 

842 """ 

843 

844 marker: str 

845 """ 

846 Footnote number or marker. 

847 

848 """ 

849 

850 

851@dataclass(eq=False, match_args=False, slots=True) 

852class FootnoteContainer(Container[Footnote]): 

853 """ 

854 Container for footnotes, enables compact rendering. 

855 

856 """ 

857 

858 

859@dataclass(kw_only=True, slots=True) 

860class Code(AstBase): 

861 """ 

862 Represents a highlighted block of code. 

863 

864 """ 

865 

866 lines: list[str] = dataclasses.field(repr=False) 

867 """ 

868 Code lines as parsed from the original document. 

869 

870 """ 

871 

872 syntax: str 

873 """ 

874 Syntax indicator as parsed form the original document. 

875 

876 """ 

877 

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 

886 

887 

888class ListEnumeratorKind(Enum): 

889 """ 

890 For enumerated lists, represents how numbers should look. 

891 

892 """ 

893 

894 NUMBER = "NUMBER" 

895 """ 

896 Numeric, i.e. ``1, 2, 3``. 

897 """ 

898 

899 SMALL_LETTER = "SMALL_LETTER" 

900 """ 

901 Small letters, i.e. ``a, b, c``. 

902 """ 

903 

904 CAPITAL_LETTER = "CAPITAL_LETTER" 

905 """ 

906 Capital letters, i.e. ``A, B, C``. 

907 """ 

908 

909 SMALL_ROMAN = "SMALL_ROMAN" 

910 """ 

911 Small roman numerals, i.e. ``i, ii, iii``. 

912 """ 

913 

914 CAPITAL_ROMAN = "CAPITAL_ROMAN" 

915 """ 

916 Capital roman numerals, i.e. ``I, II, III``. 

917 """ 

918 

919 

920class ListMarkerKind(Enum): 

921 """ 

922 For enumerated lists, represents how numbers are stylized. 

923 

924 """ 

925 

926 DOT = "DOT" 

927 """ 

928 Dot after a number, i.e. ``1.``. 

929 

930 """ 

931 

932 PAREN = "PAREN" 

933 """ 

934 Paren after a number, i.e. ``1)``. 

935 

936 """ 

937 

938 ENCLOSED = "ENCLOSED" 

939 """ 

940 Parens around a number, i.e. ``(1)``. 

941 

942 """ 

943 

944 

945@dataclass(kw_only=True, slots=True) 

946class ListItem(Container[AstBase]): 

947 """ 

948 A possibly numbered element of a list. 

949 

950 """ 

951 

952 number: int | None 

953 """ 

954 If present, this is the item's number in a numbered list. 

955 

956 """ 

957 

958 

959@dataclass(kw_only=True, slots=True) 

960class List(Container[ListItem]): 

961 """ 

962 A collection of list items. 

963 

964 """ 

965 

966 enumerator_kind: ListEnumeratorKind | str | None = None 

967 """ 

968 Enumerator kind for numbered lists, or symbol for bullet lists. 

969 

970 """ 

971 

972 marker_kind: ListMarkerKind | None = None 

973 """ 

974 Marker kind for numbered lists. 

975 

976 """ 

977 

978 

979@dataclass(kw_only=True, slots=True) 

980class NoHeadings(Container[AstBase]): 

981 """ 

982 Suppresses headings rendering for its children. 

983 

984 """ 

985 

986 

987@dataclass(kw_only=True, slots=True) 

988class Cut(AstBase): 

989 """ 

990 Stops rendering of the container. 

991 

992 """ 

993 

994 

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} 

1010 

1011 

1012def to_roman(n: int, /) -> str: 

1013 """ 

1014 Convert positive integer to lower-case roman numeral. 

1015 

1016 """ 

1017 

1018 assert n > 0 

1019 

1020 result = "" 

1021 for numeral, integer in _ROMAN_VALUES.items(): 

1022 while n >= integer: 

1023 result += numeral 

1024 n -= integer 

1025 return result 

1026 

1027 

1028def from_roman(s: str, /) -> int | None: 

1029 """ 

1030 Parse roman numeral, return :data:`None` if parsing fails. 

1031 

1032 """ 

1033 

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 

1047 

1048 

1049def to_letters(n: int, /) -> str: 

1050 """ 

1051 Convert positive integer to lowercase excel-column-like letter numeral. 

1052 

1053 """ 

1054 

1055 assert n > 0 

1056 

1057 result = "" 

1058 while n > 0: 

1059 n -= 1 

1060 result = chr(ord("a") + n % 26) + result 

1061 n //= 26 

1062 

1063 return result 

1064 

1065 

1066def from_letters(s: str, /): 

1067 """ 

1068 Parse excel-column-like letter numeral, return :data:`None` if parsing fails. 

1069 

1070 """ 

1071 

1072 if not s.isalpha(): 

1073 return None 

1074 

1075 s = s.casefold() 

1076 result = 0 

1077 

1078 for char in s: 

1079 result = result * 26 + (ord(char) - ord("a") + 1) 

1080 

1081 return result 

1082 

1083 

1084_DirectiveHandler: _t.TypeAlias = _t.Callable[ 

1085 [str, str, _t.Callable[[], list[str]], _t.Callable[[], list[AstBase]]], 

1086 _t.Sequence[AstBase], 

1087] 

1088 

1089_KNOWN_DIRECTIVES: dict[str, _DirectiveHandler] = {} 

1090 

1091 

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 ] 

1106 

1107 

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 

1113 

1114 return _registrar 

1115 

1116 

1117@_directive(["code-block", "sourcecode", "code"]) 

1118def _process_code_directive(name, arg, get_lines, get_parsed): 

1119 return [Code(lines=get_lines(), syntax=arg)] 

1120 

1121 

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)] 

1138 

1139 

1140@_directive(["admonition"]) 

1141def _process_custom_admonition_directive(name, arg, get_lines, get_parsed): 

1142 return [Admonition(title=[arg], items=get_parsed(), type=name)] 

1143 

1144 

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 ] 

1164 

1165 

1166@_directive(["if-not-sphinx", "if-opt-doc"]) 

1167def _process_id_directive(name, arg, get_lines, get_parsed): 

1168 return get_parsed() 

1169 

1170 

1171@_directive(["if-sphinx", "if-not-opt-doc"]) 

1172def _process_nop_directive(name, arg, get_lines, get_parsed): 

1173 return [] 

1174 

1175 

1176@_directive(["cut-if-not-sphinx"]) 

1177def _process_cut_directive(name, arg, get_lines, get_parsed): 

1178 return [Cut()] 

1179 

1180 

1181_CROSSREF_RE = re.compile( 

1182 r""" 

1183 ^ 

1184 (?P<title>(?:[^\\]|\\.)*?) 

1185 (?:(?<!^)\s*<(?P<target>.*)>)? 

1186 $ 

1187 """, 

1188 re.VERBOSE, 

1189) 

1190 

1191 

1192_RoleHandler: _t.TypeAlias = _t.Callable[[str, str], TextRegion] 

1193 

1194_KNOWN_ROLES: dict[str, _RoleHandler] = {} 

1195 

1196 

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 

1202 

1203 return _registrar 

1204 

1205 

1206def _process_role(text: str, role: str) -> TextRegion: 

1207 if not role: 

1208 role = "default" 

1209 

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 ) 

1218 

1219 

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) 

1227 

1228 if text.startswith("!"): 

1229 text = text[1:] 

1230 

1231 match = _CROSSREF_RE.match(text) 

1232 if not match: # pragma: no cover 

1233 return text 

1234 

1235 title = match.group("title") 

1236 target = match.group("target") 

1237 

1238 # Sphinx unescapes role contents. 

1239 title = re.sub(r"\\(?:\s|(.))", r"\1", title) 

1240 

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() 

1249 

1250 return title 

1251 

1252 

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}")) 

1278 

1279 

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}")) 

1298 

1299 

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 ) 

1316 

1317 

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 ) 

1335 

1336 

1337@_role(["guilabel"]) 

1338def _process_gui_label_role(name: str, text: str): 

1339 spans = re.split(r"(?<!&)&(?![&\s])", text) 

1340 

1341 res = NoWrapRegion() 

1342 if start := spans.pop(0): 

1343 res.content.append(HighlightedRegion(start, color=f"role/{name}")) 

1344 

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}")) 

1353 

1354 return res 

1355 

1356 

1357@_role(["menuselection"]) 

1358def _process_menuselection_role(name: str, text: str): 

1359 res = NoWrapRegion() 

1360 

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 

1374 

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 

1386 

1387 

1388@_role(["file", "samp"]) 

1389def _process_samp_role(name: str, text: str): 

1390 res = NoWrapRegion() 

1391 

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 

1423 

1424 if "".join(stack): 

1425 # remaining is treated as Text 

1426 res.content.append(HighlightedRegion("".join(stack), color=f"role/{name}")) 

1427 

1428 return res 

1429 

1430 

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") 

1436 

1437 

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] = [] 

1443 

1444 def push_res(): 

1445 nonlocal res_start 

1446 res.append(s[res_start:i]) 

1447 res_start = i 

1448 

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 

1487 

1488 push_res() 

1489 return "".join(res), "" 

1490 

1491 

1492def _parse_cfg_path(path: str) -> tuple[str, ...]: 

1493 path = re.sub(r"\s+", " ", path.strip()) 

1494 return tuple(path.split(".")) 

1495 

1496 

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) 

1505 

1506 

1507def _cmd2cfg(cmd: tuple[str, ...]) -> tuple[str, ...]: 

1508 return tuple(map(_cmd2cfg_part, cmd)) 

1509 

1510 

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 

1516 

1517 

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 

1523 

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) 

1531 

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 

1541 

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) 

1548 

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