Coverage for yuio / hl.py: 100%

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

9Yuio supports basic code highlighting; it is just enough to format help messages 

10for CLI, and color tracebacks when an error occurs. 

11 

12Yuio supports the following languages: 

13 

14- ``python``, 

15- ``traceback``, 

16- ``bash``, 

17- ``diff``, 

18- ``json``. 

19 

20 

21Highlighters registry 

22--------------------- 

23 

24.. autofunction:: get_highlighter 

25 

26.. autofunction:: register_highlighter 

27 

28 

29Highlighter base class 

30---------------------- 

31 

32.. autoclass:: SyntaxHighlighter 

33 :members: 

34 

35.. autoclass:: ReSyntaxHighlighter 

36 :members: 

37 

38 

39Implementing regexp-based highlighter 

40------------------------------------- 

41 

42Let's implement a syntax highlighter for JSON. 

43 

44We will start by creating regular expressions for JSON tokens. We will need: 

45 

46- built-in literals: ``\\b(true|false|null)\\b``, token name ``"lit/builtin"``; 

47- numbers: ``-?\\d+(\\.\\d+)?([eE][+-]?\\d+)?``, token name ``lit/num``; 

48- strings: ``"(\\\\.|[^\\\\"])*"``, token name ``str``; 

49- punctuation: ``[{}\\[\\],:]``, token name ``punct``. 

50 

51Now that we know our tokens and regular expressions to parse them, we can pass them 

52to :class:`ReSyntaxHighlighter`: 

53 

54.. code-block:: python 

55 

56 json_highlighter = yuio.hl.ReSyntaxHighlighter( 

57 [ 

58 ( 

59 # Literals. 

60 r"\\b(true|false|null)\\b", 

61 "lit/builtin", 

62 ), 

63 ( 

64 # Numbers. 

65 r"-?\\d+(\\.\\d+)?([eE][+-]?\\d+)?", 

66 "lit/num", 

67 ), 

68 ( 

69 # Strings. 

70 r'"(\\\\.|[^\\\\"])*"', 

71 "str", 

72 ), 

73 ( 

74 # Punctuation. 

75 r"[{}\\[\\],:]", 

76 "punct", 

77 ), 

78 ], 

79 ) 

80 

81:class:`ReSyntaxHighlighter` will scan source code and look for given regular 

82expressions. If found, it will color matched part of the code depending on the 

83associated token name. 

84 

85We can also color different parts of matched code with different colors, and even 

86pass them to nested syntax highlighters. 

87 

88For example, let's define a highlighter that searches for escape sequences in strings: 

89 

90.. code-block:: python 

91 

92 str_highlighter = yuio.hl.ReSyntaxHighlighter( 

93 [ 

94 ( 

95 # Escape sequence. 

96 r'\\([\\/"bfnrt]|u[0-9a-fA-F]{4})', 

97 "str/esc", 

98 ) 

99 ], 

100 base_color="str", 

101 ) 

102 

103We can now apply ``str_highlighter`` to strings when ``json_highlighter`` matches them: 

104 

105.. code-block:: python 

106 

107 json_highlighter = yuio.hl.ReSyntaxHighlighter( 

108 [ 

109 ..., 

110 ( 

111 # Strings. 

112 r'(")(\\\\.|[^\\\\"])*(")', 

113 ("str", str_highlighter, "str"), 

114 ), 

115 ..., 

116 ], 

117 ) 

118 

119Our regular expression for strings contains three capturing groups: 

120 

121.. raw:: html 

122 

123 <div class="highlight-text notranslate"> 

124 <div class="highlight"> 

125 <pre class="ascii-graphics"> 

126 <span class="k">(</span>"<span class="k">)</span><span class="k">(</span><span class="w">(?:</span>\\\\.|[^\\\\"]<span class="w">)</span>*<span class="k">)(</span>"<span class="k">)</span> 

127 │ └┬────────────┘ │ 

128 │ │ #2, matches closing quote 

129 │ └ #1, matches string content 

130 └ #0, matches opening quote 

131 </pre> 

132 </div> 

133 </div> 

134 

135And we've passed token names and highlighters for each of these groups: 

136 

137.. raw:: html 

138 

139 <div class="highlight-text notranslate"> 

140 <div class="highlight"> 

141 <pre class="ascii-graphics"> 

142 (<span class="s">"str"</span>, str_highlighter, <span class="s">"str"</span>) 

143 └┬──┘ └┬────────────┘ └┬──┘ 

144 │ │ │ 

145 │ │ highlights contents of group #2 

146 │ └ highlights contents of group #1 

147 └ highlights contents of group #0 

148 </pre> 

149 </div> 

150 </div> 

151 

152""" 

153 

154from __future__ import annotations 

155 

156import abc 

157import functools 

158import re 

159 

160import yuio.color 

161import yuio.string 

162import yuio.theme 

163 

164import yuio._typing_ext as _tx 

165from typing import TYPE_CHECKING 

166 

167if TYPE_CHECKING: 

168 import typing_extensions as _t 

169else: 

170 from yuio import _typing as _t 

171 

172__all__ = [ 

173 "ReSyntaxHighlighter", 

174 "SyntaxHighlighter", 

175 "TokenDescription", 

176 "TokenHighlight", 

177 "get_highlighter", 

178 "register_highlighter", 

179] 

180 

181 

182class SyntaxHighlighter(abc.ABC): 

183 @abc.abstractmethod 

184 def highlight( 

185 self, 

186 code: str, 

187 /, 

188 *, 

189 theme: yuio.theme.Theme, 

190 syntax: str, 

191 default_color: yuio.color.Color | str | None = None, 

192 ) -> yuio.string.ColorizedString: 

193 """ 

194 Highlight the given code using the given theme. 

195 

196 :param code: 

197 code to highlight. 

198 :param syntax: 

199 canonical name of the syntax. 

200 :param theme: 

201 theme that will be used to look up color tags. 

202 :param default_color: 

203 color or color path to apply to the entire code. 

204 

205 """ 

206 

207 raise NotImplementedError() 

208 

209 def _get_default_color( 

210 self, 

211 theme: yuio.theme.Theme, 

212 default_color: yuio.color.Color | str | None, 

213 syntax: str, 

214 ) -> yuio.color.Color: 

215 return theme.to_color(default_color) | theme.get_color( 

216 f"msg/text:code/{syntax}" 

217 ) 

218 

219 

220_SYNTAXES: dict[str, tuple[SyntaxHighlighter, str]] = {} 

221 

222 

223def register_highlighter(syntaxes: list[str], highlighter: SyntaxHighlighter): 

224 """ 

225 Register a highlighter in a global registry, and allow looking it up 

226 via the :meth:`~get_highlighter` method. 

227 

228 :param syntaxes: 

229 syntax names which correspond to this highlighter. The first syntax 

230 is considered *canonical*, meaning that it should be used to look up 

231 colors in a theme. 

232 :param highlighter: 

233 a highlighter instance. 

234 

235 """ 

236 

237 canonical_syntax = syntaxes[0] 

238 for syntax in syntaxes: 

239 _SYNTAXES[syntax.lower().replace("_", "-")] = highlighter, canonical_syntax 

240 

241 

242def get_highlighter(syntax: str, /) -> tuple[SyntaxHighlighter, str]: 

243 """ 

244 Look up highlighter by a syntax name. 

245 

246 :param syntax: 

247 name of the syntax highlighter. 

248 :returns: 

249 a highlighter instance and a string with canonical syntax name. 

250 If highlighter with the given name can't be found, returns a dummy 

251 highlighter that does nothing. 

252 :example: 

253 .. invisible-code-block: python 

254 

255 import yuio.hl, yuio.theme 

256 code = "" 

257 theme = yuio.theme.Theme() 

258 

259 .. code-block:: python 

260 

261 highlighter, syntax_name = yuio.hl.get_highlighter("python") 

262 

263 highlighted = highlighter.highlight( 

264 code, 

265 theme=theme, 

266 syntax=syntax_name, 

267 ) 

268 

269 """ 

270 

271 return _SYNTAXES.get(syntax.lower().replace("_", "-")) or ( 

272 _DummySyntaxHighlighter(), 

273 syntax, 

274 ) 

275 

276 

277class _DummySyntaxHighlighter(SyntaxHighlighter): 

278 def highlight( 

279 self, 

280 code: str, 

281 /, 

282 *, 

283 theme: yuio.theme.Theme, 

284 syntax: str, 

285 default_color: yuio.color.Color | str | None = None, 

286 ) -> yuio.string.ColorizedString: 

287 return yuio.string.ColorizedString( 

288 self._get_default_color(theme, default_color, syntax), 

289 code, 

290 yuio.color.Color.NONE, 

291 ) 

292 

293 

294register_highlighter(["text", "plain", "plain-text"], _DummySyntaxHighlighter()) 

295 

296 

297TokenHighlight: _t.TypeAlias = ( 

298 str | SyntaxHighlighter | None | tuple[str | SyntaxHighlighter | None, ...] 

299) 

300""" 

301See :class:`ReSyntaxHighlighter`. 

302 

303""" 

304 

305TokenDescription: _t.TypeAlias = tuple[str, TokenHighlight] 

306""" 

307See :class:`ReSyntaxHighlighter`. 

308 

309""" 

310 

311 

312class ReSyntaxHighlighter(SyntaxHighlighter): 

313 """ 

314 A highlighter implementation that uses regular expressions to tokenize source code. 

315 

316 This highlighter accepts regular expressions for tokens, and corresponding token 

317 names. 

318 

319 Regular expressions are compiled with flag :data:`re.MULTILINE`; they should not 

320 contain global flags or named groups. :class:`ReSyntaxHighlighter` will combine 

321 all given regexps into a single regular expression, and run it using 

322 :func:`re.finditer` (similar to a tokenizer example from `Python documentation`__. 

323 

324 __ https://docs.python.org/3/library/re.html#writing-a-tokenizer 

325 

326 :param patterns: 

327 regular expressions and corresponding colors that will be used to tokenize 

328 code. 

329 

330 Each pattern should be a tuple of two elements: 

331 

332 - the first is a string with a regular expression, which will be combined 

333 with multiline flag; 

334 

335 - the second is name of a token, or another :class:`SyntaxHighlighter`. 

336 

337 It can also be a tuple of token names and syntax highlighters, one for 

338 every capturing group in the regular expression. 

339 

340 Token names will be converted to colors by looking up 

341 :color-path:`hl/{token}:{syntax}` in a :class:`~yuio.theme.Theme`. 

342 :param base_color: 

343 color that will be added to the entire code regardless of tokens 

344 

345 """ 

346 

347 def __init__( 

348 self, 

349 patterns: list[TokenDescription], 

350 *, 

351 base_color: str | None = None, 

352 ): 

353 self._patterns = patterns 

354 self._base_color = base_color 

355 

356 @functools.cached_property 

357 def _tokenizer_data(self): 

358 first_group = 0 

359 all_patterns = [] 

360 all_groups: dict[str, tuple[int, TokenHighlight]] = {} 

361 for i, (pattern, groups) in enumerate(self._patterns): 

362 first_group += 1 

363 pattern = re.compile(pattern, re.MULTILINE) 

364 pattern_name = f"_p_{i}_" 

365 all_patterns.append(f"(?P<{pattern_name}>{pattern.pattern})") 

366 all_groups[pattern_name] = (first_group, groups) 

367 first_group += pattern.groups 

368 return re.compile("|".join(all_patterns), re.MULTILINE), all_groups 

369 

370 def highlight( 

371 self, 

372 code: str, 

373 /, 

374 *, 

375 theme: yuio.theme.Theme, 

376 syntax: str, 

377 default_color: yuio.color.Color | str | None = None, 

378 ) -> yuio.string.ColorizedString: 

379 default_color = self._get_default_color(theme, default_color, syntax) 

380 if self._base_color: 

381 default_color = default_color | theme.get_color( 

382 f"hl/{self._base_color}:{syntax}" 

383 ) 

384 

385 res = yuio.string.ColorizedString() 

386 pattern, all_groups = self._tokenizer_data 

387 pos = 0 

388 for match in pattern.finditer(code): 

389 start, end = match.span() 

390 if pos < start: 

391 res.append_color(default_color) 

392 res.append_str(code[pos:start]) 

393 assert match.lastgroup 

394 first_group_index, groups = all_groups[match.lastgroup] 

395 self._process_groups( 

396 syntax, theme, default_color, res, match, first_group_index, groups 

397 ) 

398 pos = end 

399 if pos < len(code): 

400 res.append_color(default_color) 

401 res.append_str(code[pos:]) 

402 

403 return res 

404 

405 def _process_groups( 

406 self, 

407 syntax: str, 

408 theme: yuio.theme.Theme, 

409 default_color: yuio.color.Color, 

410 res: yuio.string.ColorizedString, 

411 match: _tx.StrReMatch, 

412 first_group_index: int, 

413 groups: TokenHighlight, 

414 ): 

415 if not groups: 

416 res.append_color(default_color) 

417 res.append_str(match.group()) 

418 elif isinstance(groups, str): 

419 res.append_color(default_color | theme.get_color(f"hl/{groups}:{syntax}")) 

420 res.append_str(match.group()) 

421 elif isinstance(groups, SyntaxHighlighter): 

422 res.append_colorized_str( 

423 groups.highlight( 

424 match.group(), 

425 theme=theme, 

426 syntax=syntax, 

427 default_color=default_color, 

428 ) 

429 ) 

430 else: 

431 pos = match.start() 

432 code = match.string 

433 for i, text in enumerate(match.groups()): 

434 if not text or i < first_group_index: 

435 continue 

436 elif i - first_group_index >= len(groups): 

437 break 

438 group = groups[i - first_group_index] 

439 if not group: 

440 continue 

441 start = match.start(i + 1) 

442 end = match.end(i + 1) 

443 if start < pos: 

444 continue 

445 elif start > pos: 

446 res.append_color(default_color) 

447 res.append_str(code[pos:start]) 

448 if isinstance(group, str): 

449 res.append_color( 

450 default_color | theme.get_color(f"hl/{group}:{syntax}") 

451 ) 

452 res.append_str(code[start:end]) 

453 elif isinstance(group, SyntaxHighlighter): 

454 res.append_colorized_str( 

455 group.highlight( 

456 code[start:end], 

457 theme=theme, 

458 syntax=syntax, 

459 default_color=default_color, 

460 ) 

461 ) 

462 pos = end 

463 if pos < match.end(): 

464 res.append_color(default_color) 

465 res.append_str(code[pos : match.end()]) 

466 

467 

468_PY_STRING_ESCAPES = ReSyntaxHighlighter( 

469 [ 

470 ( 

471 r"\\[\n\'\"\\abfnrtv]", 

472 "str/esc", 

473 ), 

474 ( 

475 r"\\[0-7]{3}", 

476 "str/esc", 

477 ), 

478 ( 

479 r"\\x[0-9a-fA-F]{2}", 

480 "str/esc", 

481 ), 

482 ( 

483 r"\\u[0-9a-fA-F]{4}", 

484 "str/esc", 

485 ), 

486 ( 

487 r"\\U[0-9a-fA-F]{8}", 

488 "str/esc", 

489 ), 

490 ( 

491 r"\\N\{[^}\n]+\}", 

492 "str/esc", 

493 ), 

494 ( 

495 r"{{|}}", 

496 "str/esc", 

497 ), 

498 ( 

499 r"{[^}]*?}", 

500 "str/esc", 

501 ), 

502 ( 

503 r"%(?:\([^)]*\))?[#0\-+ ]*(?:\*|\d+)?(?:\.(?:\*|\d*))?[hlL]?.", 

504 "str/esc", 

505 ), 

506 ], 

507 base_color="str", 

508) 

509 

510 

511_PY_HIGHLIGHTER_INNER = ReSyntaxHighlighter( 

512 [ 

513 ( 

514 r"\b(?:and|as|assert|async|await|break|class|continue|def|del|elif|else|except" 

515 r"|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|raise" 

516 r"|return|try|while|with|yield)\b", 

517 "kwd", 

518 ), 

519 ( 

520 r'([rfut]*)(""")((?:\\.|[^\\]|\n)*?)(?:(""")|($))', 

521 ("str/prefix", "str", _PY_STRING_ESCAPES, "str", "error"), 

522 ), 

523 ( 

524 r"([rfut]*)(''')((?:\\.|[^\\]|\n)*?)(?:(''')|($))", 

525 ("str/prefix", "str", _PY_STRING_ESCAPES, "str", "error"), 

526 ), 

527 ( 

528 r'([rfut]*)(")((?:\\.|[^\\"])*)(?:(")|(\n|$))', 

529 ("str/prefix", "str", _PY_STRING_ESCAPES, "str", "error"), 

530 ), 

531 ( 

532 r"([rfut]*)(')((?:\\.|[^\\'])*)(?:(')|(\n|$))", 

533 ("str/prefix", "str", _PY_STRING_ESCAPES, "str", "error"), 

534 ), 

535 ( 

536 r"(?<![\.\w])[+-]?\d+(?:\.\d*(?:e[+-]?\d+)?)?", 

537 "lit/num/dec", 

538 ), 

539 ( 

540 r"(?<![\.\w])[+-]?\.\d+(?:e[+-]?\d+)?", 

541 "lit/num/dec", 

542 ), 

543 ( 

544 r"(?<![\.\w])[+-]?0x[0-9a-fA-F]+", 

545 "lit/num/hex", 

546 ), 

547 ( 

548 r"(?<![\.\w])[+-]?0o[0-7]+", 

549 "lit/num/oct", 

550 ), 

551 ( 

552 r"(?<![\.\w])[+-]?0b[01]+", 

553 "lit/num/bin", 

554 ), 

555 ( 

556 r"(?<![\.\w])\b(?:None|True|False)\b", 

557 "lit/builtin", 

558 ), 

559 ( 

560 r"\b(?:str|int|float|complex|list|tuple|range|dict|set|frozenset|bool" 

561 r"|bytes|bytearray|memoryview)\b", 

562 "type/builtin", 

563 ), 

564 ( 

565 r"\b(?:[A-Z](?:[A-Z0-9_]*?[a-z]\w*)?)\b", 

566 "type/user", 

567 ), 

568 ( 

569 r"[{}()\[\]\\;,]", 

570 "punct", 

571 ), 

572 ( 

573 r"\#.*$", 

574 "comment", 

575 ), 

576 ], 

577) 

578 

579 

580class _PyHighlighter(SyntaxHighlighter): 

581 def highlight( 

582 self, 

583 code: str, 

584 /, 

585 *, 

586 theme: yuio.theme.Theme, 

587 syntax: str, 

588 default_color: yuio.color.Color | str | None = None, 

589 ) -> yuio.string.ColorizedString: 

590 if not code.startswith(">>>"): 

591 return _PY_HIGHLIGHTER_INNER.highlight( 

592 code, theme=theme, syntax=syntax, default_color=default_color 

593 ) 

594 

595 default_color = theme.to_color(default_color) 

596 

597 blocks = [] 

598 

599 PLAIN_TEXT, CODE = 1, 2 

600 state = PLAIN_TEXT 

601 

602 block: list[str] = [] 

603 results: list[str] = [] 

604 for line in code.splitlines(keepends=True): 

605 if state == PLAIN_TEXT: 

606 if line.startswith(">>>"): 

607 if block: 

608 blocks.append((block, results)) 

609 state = CODE 

610 block = [line[3:]] 

611 results = [] 

612 else: 

613 results.append(line) 

614 else: 

615 if line.startswith("..."): 

616 block.append(line[3:]) 

617 else: 

618 results.append(line) 

619 state = PLAIN_TEXT 

620 if block: 

621 blocks.append((block, results)) 

622 

623 res = yuio.string.ColorizedString(default_color) 

624 indent_a = yuio.string.ColorizedString( 

625 default_color | theme.get_color(f"hl/doctest_marker/start:{syntax}"), 

626 ">>>", 

627 ) 

628 indent_b = yuio.string.ColorizedString( 

629 default_color | theme.get_color(f"hl/doctest_marker/continue:{syntax}"), 

630 "...", 

631 ) 

632 

633 for block, results in blocks: 

634 code = "".join(block) 

635 res.append_colorized_str( 

636 _PY_HIGHLIGHTER_INNER.highlight( 

637 code, 

638 theme=theme, 

639 syntax=syntax, 

640 default_color=default_color, 

641 ).indent(indent_a, indent_b) 

642 ) 

643 res.append_str("".join(results)) 

644 

645 return res 

646 

647 

648register_highlighter( 

649 ["py", "py3", "py-3", "python", "python3", "python-3", "repr"], 

650 _PyHighlighter(), 

651) 

652 

653 

654register_highlighter( 

655 ["sh", "bash", "console"], 

656 ReSyntaxHighlighter( 

657 [ 

658 ( 

659 r"\b(?:if|then|elif|else|fi|time|for|in|until|while|do|done|case|" 

660 r"esac|coproc|select|function)\b", 

661 "kwd", 

662 ), 

663 ( 

664 r"\[\[", 

665 "kwd", 

666 ), 

667 ( 

668 r"\]\]", 

669 "kwd", 

670 ), 

671 ( 

672 r"(^|\|\|?|&&|\$\()(?:\s*)([\w./~]([\w.@/-]|\\.)+)", 

673 ("kwd", "prog"), 

674 ), 

675 ( 

676 r"(^\$)(?:\s*)([\w./~]([\w.@/-]|\\.)+)", 

677 ("punct", "prog"), 

678 ), 

679 ( 

680 r"\|\|?|&&", 

681 "kwd", 

682 ), 

683 ( 

684 r"'[^']*'", 

685 "str", 

686 ), 

687 ( 

688 r'"(?:\\.|[^\\"])*"', 

689 "str", 

690 ), 

691 ( 

692 r"<{1,3}", 

693 "kwd", 

694 ), 

695 ( 

696 r"[12]?>{1,2}(?:&[12])?", 

697 "kwd", 

698 ), 

699 ( 

700 r"\#.*$", 

701 "comment", 

702 ), 

703 ( 

704 r"(?<![\w-])-[a-zA-Z0-9_-]+\b", 

705 "flag", 

706 ), 

707 ( 

708 r"[{}()\[\]\\;!&|]", 

709 "punct", 

710 ), 

711 ], 

712 ), 

713) 

714 

715register_highlighter( 

716 ["sh-usage", "bash-usage"], 

717 ReSyntaxHighlighter( 

718 [ 

719 ( 

720 r"\b(?:if|then|elif|else|fi|time|for|in|until|while|do|done|case|" 

721 r"esac|coproc|select|function)\b", 

722 "kwd", 

723 ), 

724 ( 

725 r"%\(prog\)s", 

726 "prog", 

727 ), 

728 ( 

729 r"'[^']*'", 

730 "str", 

731 ), 

732 ( 

733 r'"(?:\\.|[^\\"])*"', 

734 "str", 

735 ), 

736 ( 

737 r"\#.*$", 

738 "comment", 

739 ), 

740 ( 

741 r"(?<![\w-])-[a-zA-Z0-9_-]+\b", 

742 "flag", 

743 ), 

744 ( 

745 r"<options>", 

746 "flag", 

747 ), 

748 ( 

749 r"<[^>]+>", 

750 "metavar", 

751 ), 

752 ( 

753 r"[{}()\[\]\\;!&|]", 

754 "punct", 

755 ), 

756 ( 

757 r"^\$", 

758 "punct", 

759 ), 

760 ( 

761 r"(?<=[{(\[|])(?!\s)([^})\]|\n\r\t\v\b]*?)(?<!\s)(?=[})\]|])", 

762 "metavar", 

763 ), 

764 ], 

765 ), 

766) 

767 

768register_highlighter( 

769 ["diff"], 

770 ReSyntaxHighlighter( 

771 [ 

772 ( 

773 r"^(\-\-\-|\+\+\+|\@\@)[^\r\n]*$", 

774 "meta", 

775 ), 

776 ( 

777 r"^\+[^\r\n]*$", 

778 "added", 

779 ), 

780 ( 

781 r"^\-[^\r\n]*$", 

782 "removed", 

783 ), 

784 ], 

785 ), 

786) 

787 

788_JSON_STRING_ESCAPES = ReSyntaxHighlighter( 

789 [ 

790 ( 

791 r'\\([\\/"bfnrt]|u[0-9a-fA-F]{4})', 

792 "str/esc", 

793 ) 

794 ], 

795 base_color="str", 

796) 

797 

798register_highlighter( 

799 ["json"], 

800 ReSyntaxHighlighter( 

801 [ 

802 ( 

803 r"\b(?:true|false|null)\b", 

804 "lit/builtin", 

805 ), 

806 ( 

807 r"-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?", 

808 "lit/num/dec", 

809 ), 

810 ( 

811 r'(")((?:\\.|[^\\"])*)(")', 

812 ("str", _JSON_STRING_ESCAPES, "str"), 

813 ), 

814 ( 

815 r"[{}\[\],:]", 

816 "punct", 

817 ), 

818 ], 

819 ), 

820) 

821 

822 

823class _TbHighlighter(SyntaxHighlighter): 

824 class _StackColors: 

825 def __init__( 

826 self, theme: yuio.theme.Theme, default_color: yuio.color.Color, tag: str 

827 ): 

828 self.file_color = default_color | theme.get_color(f"tb/frame/{tag}/file") 

829 self.file_path_color = default_color | theme.get_color( 

830 f"tb/frame/{tag}/file/path" 

831 ) 

832 self.file_line_color = default_color | theme.get_color( 

833 f"tb/frame/{tag}/file/line" 

834 ) 

835 self.file_module_color = default_color | theme.get_color( 

836 f"tb/frame/{tag}/file/module" 

837 ) 

838 self.code_color = default_color | theme.get_color(f"tb/frame/{tag}/code") 

839 self.highlight_color = default_color | theme.get_color( 

840 f"tb/frame/{tag}/highlight" 

841 ) 

842 

843 _TB_RE = re.compile( 

844 r"^(?P<indent>[ |+]*)(Stack|Traceback|Exception Group Traceback) \(most recent call last\):$" 

845 ) 

846 _TB_MSG_RE = re.compile(r"^(?P<indent>[ |+]*)[A-Za-z_][A-Za-z0-9_]*($|:.*$)") 

847 _TB_LINE_FILE = re.compile( 

848 r'^[ |+]*File (?P<file>"[^"]*"), line (?P<line>\d+)(?:, in (?P<loc>.*))?$' 

849 ) 

850 _TB_LINE_HIGHLIGHT = re.compile(r"^[ |+^~-]*$") 

851 _SITE_PACKAGES = re.compile(r"[/\\]lib[/\\]site-packages[/\\]") 

852 _LIB_PYTHON = re.compile(r"[/\\]lib[/\\]python") 

853 

854 def highlight( 

855 self, 

856 code: str, 

857 /, 

858 *, 

859 theme: yuio.theme.Theme, 

860 syntax: str, 

861 default_color: yuio.color.Color | str | None = None, 

862 ) -> yuio.string.ColorizedString: 

863 default_color = self._get_default_color(theme, default_color, syntax) 

864 

865 py_highlighter, py_highlighter_syntax_name = get_highlighter("py") 

866 

867 heading_color = default_color | theme.get_color("tb/heading") 

868 message_color = default_color | theme.get_color("tb/message") 

869 

870 stack_normal_colors = self._StackColors(theme, default_color, "usr") 

871 stack_lib_colors = self._StackColors(theme, default_color, "lib") 

872 stack_colors = stack_normal_colors 

873 

874 res = yuio.string.ColorizedString() 

875 

876 PLAIN_TEXT, STACK, MESSAGE = 1, 2, 3 

877 state = PLAIN_TEXT 

878 stack_indent = "" 

879 message_indent = "" 

880 

881 for line in code.splitlines(keepends=True): 

882 if state is STACK: 

883 if line.startswith(stack_indent): 

884 # We're still in the stack. 

885 if match := self._TB_LINE_FILE.match(line): 

886 file, line, loc = match.group("file", "line", "loc") 

887 

888 if self._SITE_PACKAGES.search(file) or self._LIB_PYTHON.search( 

889 file 

890 ): 

891 stack_colors = stack_lib_colors 

892 else: 

893 stack_colors = stack_normal_colors 

894 

895 res += yuio.color.Color.NONE 

896 res += stack_indent 

897 res += stack_colors.file_color 

898 res += "File " 

899 res += stack_colors.file_path_color 

900 res += file 

901 res += stack_colors.file_color 

902 res += ", line " 

903 res += stack_colors.file_line_color 

904 res += line 

905 res += stack_colors.file_color 

906 

907 if loc: 

908 res += ", in " 

909 res += stack_colors.file_module_color 

910 res += loc 

911 res += stack_colors.file_color 

912 

913 res += "\n" 

914 elif match := self._TB_LINE_HIGHLIGHT.match(line): 

915 res += yuio.color.Color.NONE 

916 res += stack_indent 

917 res += stack_colors.highlight_color 

918 res += line[len(stack_indent) :] 

919 else: 

920 res += yuio.color.Color.NONE 

921 res += stack_indent 

922 res += py_highlighter.highlight( 

923 line[len(stack_indent) :], 

924 theme=theme, 

925 syntax=py_highlighter_syntax_name, 

926 default_color=stack_colors.code_color, 

927 ) 

928 continue 

929 else: 

930 # Stack has ended, this line is actually a message. 

931 state = MESSAGE 

932 

933 if state is MESSAGE: 

934 if line and line != "\n" and line.startswith(message_indent): 

935 # We're still in the message. 

936 res += yuio.color.Color.NONE 

937 res += message_indent 

938 res += message_color 

939 res += line[len(message_indent) :] 

940 continue 

941 else: 

942 # Message has ended, this line is actually a plain text. 

943 state = PLAIN_TEXT 

944 

945 if state is PLAIN_TEXT: 

946 if match := self._TB_RE.match(line): 

947 # Plain text has ended, this is actually a heading. 

948 message_indent = match.group("indent").replace("+", "|") 

949 stack_indent = message_indent + " " 

950 

951 res += yuio.color.Color.NONE 

952 res += message_indent 

953 res += heading_color 

954 res += line[len(message_indent) :] 

955 

956 state = STACK 

957 continue 

958 elif match := self._TB_MSG_RE.match(line): 

959 # Plain text has ended, this is an error message (without a traceback). 

960 message_indent = match.group("indent").replace("+", "|") 

961 stack_indent = message_indent + " " 

962 

963 res += yuio.color.Color.NONE 

964 res += message_indent 

965 res += message_color 

966 res += line[len(message_indent) :] 

967 

968 state = MESSAGE 

969 continue 

970 else: 

971 # We're still in plain text. 

972 res += yuio.color.Color.NONE 

973 res += line 

974 continue 

975 

976 return res 

977 

978 

979register_highlighter( 

980 [ 

981 "tb", 

982 "traceback", 

983 "py-tb", 

984 "py3-tb", 

985 "py-3-tb", 

986 "py-traceback", 

987 "py3-traceback", 

988 "py-3-traceback", 

989 "python-tb", 

990 "python3-tb", 

991 "python-3-tb", 

992 "python-traceback", 

993 "python3-traceback", 

994 "python-3-traceback", 

995 ], 

996 _TbHighlighter(), 

997)