Coverage for yuio / hl.py: 100%
237 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"""
9Yuio supports basic code highlighting; it is just enough to format help messages
10for CLI, and color tracebacks when an error occurs.
12Yuio supports the following languages:
14- ``python``,
15- ``traceback``,
16- ``bash``,
17- ``diff``,
18- ``json``.
21Highlighters registry
22---------------------
24.. autofunction:: get_highlighter
26.. autofunction:: register_highlighter
29Highlighter base class
30----------------------
32.. autoclass:: SyntaxHighlighter
33 :members:
35.. autoclass:: ReSyntaxHighlighter
36 :members:
39Implementing regexp-based highlighter
40-------------------------------------
42Let's implement a syntax highlighter for JSON.
44We will start by creating regular expressions for JSON tokens. We will need:
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``.
51Now that we know our tokens and regular expressions to parse them, we can pass them
52to :class:`ReSyntaxHighlighter`:
54.. code-block:: python
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 )
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.
85We can also color different parts of matched code with different colors, and even
86pass them to nested syntax highlighters.
88For example, let's define a highlighter that searches for escape sequences in strings:
90.. code-block:: python
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 )
103We can now apply ``str_highlighter`` to strings when ``json_highlighter`` matches them:
105.. code-block:: python
107 json_highlighter = yuio.hl.ReSyntaxHighlighter(
108 [
109 ...,
110 (
111 # Strings.
112 r'(")(\\\\.|[^\\\\"])*(")',
113 ("str", str_highlighter, "str"),
114 ),
115 ...,
116 ],
117 )
119Our regular expression for strings contains three capturing groups:
121.. raw:: html
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>
135And we've passed token names and highlighters for each of these groups:
137.. raw:: html
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>
152"""
154from __future__ import annotations
156import abc
157import functools
158import re
160import yuio.color
161import yuio.string
162import yuio.theme
164import yuio._typing_ext as _tx
165from typing import TYPE_CHECKING
167if TYPE_CHECKING:
168 import typing_extensions as _t
169else:
170 from yuio import _typing as _t
172__all__ = [
173 "ReSyntaxHighlighter",
174 "SyntaxHighlighter",
175 "TokenDescription",
176 "TokenHighlight",
177 "get_highlighter",
178 "register_highlighter",
179]
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.
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.
205 """
207 raise NotImplementedError()
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 )
220_SYNTAXES: dict[str, tuple[SyntaxHighlighter, str]] = {}
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.
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.
235 """
237 canonical_syntax = syntaxes[0]
238 for syntax in syntaxes:
239 _SYNTAXES[syntax.lower().replace("_", "-")] = highlighter, canonical_syntax
242def get_highlighter(syntax: str, /) -> tuple[SyntaxHighlighter, str]:
243 """
244 Look up highlighter by a syntax name.
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
255 import yuio.hl, yuio.theme
256 code = ""
257 theme = yuio.theme.Theme()
259 .. code-block:: python
261 highlighter, syntax_name = yuio.hl.get_highlighter("python")
263 highlighted = highlighter.highlight(
264 code,
265 theme=theme,
266 syntax=syntax_name,
267 )
269 """
271 return _SYNTAXES.get(syntax.lower().replace("_", "-")) or (
272 _DummySyntaxHighlighter(),
273 syntax,
274 )
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 )
294register_highlighter(["text", "plain", "plain-text"], _DummySyntaxHighlighter())
297TokenHighlight: _t.TypeAlias = (
298 str | SyntaxHighlighter | None | tuple[str | SyntaxHighlighter | None, ...]
299)
300"""
301See :class:`ReSyntaxHighlighter`.
303"""
305TokenDescription: _t.TypeAlias = tuple[str, TokenHighlight]
306"""
307See :class:`ReSyntaxHighlighter`.
309"""
312class ReSyntaxHighlighter(SyntaxHighlighter):
313 """
314 A highlighter implementation that uses regular expressions to tokenize source code.
316 This highlighter accepts regular expressions for tokens, and corresponding token
317 names.
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`__.
324 __ https://docs.python.org/3/library/re.html#writing-a-tokenizer
326 :param patterns:
327 regular expressions and corresponding colors that will be used to tokenize
328 code.
330 Each pattern should be a tuple of two elements:
332 - the first is a string with a regular expression, which will be combined
333 with multiline flag;
335 - the second is name of a token, or another :class:`SyntaxHighlighter`.
337 It can also be a tuple of token names and syntax highlighters, one for
338 every capturing group in the regular expression.
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
345 """
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
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
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 )
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:])
403 return res
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()])
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)
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)
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 )
595 default_color = theme.to_color(default_color)
597 blocks = []
599 PLAIN_TEXT, CODE = 1, 2
600 state = PLAIN_TEXT
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))
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 )
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))
645 return res
648register_highlighter(
649 ["py", "py3", "py-3", "python", "python3", "python-3", "repr"],
650 _PyHighlighter(),
651)
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)
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)
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)
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)
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)
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 )
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")
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)
865 py_highlighter, py_highlighter_syntax_name = get_highlighter("py")
867 heading_color = default_color | theme.get_color("tb/heading")
868 message_color = default_color | theme.get_color("tb/message")
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
874 res = yuio.string.ColorizedString()
876 PLAIN_TEXT, STACK, MESSAGE = 1, 2, 3
877 state = PLAIN_TEXT
878 stack_indent = ""
879 message_indent = ""
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")
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
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
907 if loc:
908 res += ", in "
909 res += stack_colors.file_module_color
910 res += loc
911 res += stack_colors.file_color
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
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
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 + " "
951 res += yuio.color.Color.NONE
952 res += message_indent
953 res += heading_color
954 res += line[len(message_indent) :]
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 + " "
963 res += yuio.color.Color.NONE
964 res += message_indent
965 res += message_color
966 res += line[len(message_indent) :]
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
976 return res
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)