]>
Commit | Line | Data |
---|---|---|
e0df8241 JR |
1 | import os.path |
2 | import platform | |
3 | import re | |
4 | import sys | |
5 | import textwrap | |
6 | from abc import ABC, abstractmethod | |
7 | from pathlib import Path | |
8 | from typing import ( | |
9 | Any, | |
10 | Dict, | |
11 | Iterable, | |
12 | List, | |
13 | NamedTuple, | |
14 | Optional, | |
15 | Sequence, | |
16 | Set, | |
17 | Tuple, | |
18 | Type, | |
19 | Union, | |
20 | ) | |
21 | ||
22 | from pip._vendor.pygments.lexer import Lexer | |
23 | from pip._vendor.pygments.lexers import get_lexer_by_name, guess_lexer_for_filename | |
24 | from pip._vendor.pygments.style import Style as PygmentsStyle | |
25 | from pip._vendor.pygments.styles import get_style_by_name | |
26 | from pip._vendor.pygments.token import ( | |
27 | Comment, | |
28 | Error, | |
29 | Generic, | |
30 | Keyword, | |
31 | Name, | |
32 | Number, | |
33 | Operator, | |
34 | String, | |
35 | Token, | |
36 | Whitespace, | |
37 | ) | |
38 | from pip._vendor.pygments.util import ClassNotFound | |
39 | ||
40 | from pip._vendor.rich.containers import Lines | |
41 | from pip._vendor.rich.padding import Padding, PaddingDimensions | |
42 | ||
43 | from ._loop import loop_first | |
44 | from .cells import cell_len | |
45 | from .color import Color, blend_rgb | |
46 | from .console import Console, ConsoleOptions, JustifyMethod, RenderResult | |
47 | from .jupyter import JupyterMixin | |
48 | from .measure import Measurement | |
49 | from .segment import Segment, Segments | |
50 | from .style import Style, StyleType | |
51 | from .text import Text | |
52 | ||
53 | TokenType = Tuple[str, ...] | |
54 | ||
55 | WINDOWS = platform.system() == "Windows" | |
56 | DEFAULT_THEME = "monokai" | |
57 | ||
58 | # The following styles are based on https://github.com/pygments/pygments/blob/master/pygments/formatters/terminal.py | |
59 | # A few modifications were made | |
60 | ||
61 | ANSI_LIGHT: Dict[TokenType, Style] = { | |
62 | Token: Style(), | |
63 | Whitespace: Style(color="white"), | |
64 | Comment: Style(dim=True), | |
65 | Comment.Preproc: Style(color="cyan"), | |
66 | Keyword: Style(color="blue"), | |
67 | Keyword.Type: Style(color="cyan"), | |
68 | Operator.Word: Style(color="magenta"), | |
69 | Name.Builtin: Style(color="cyan"), | |
70 | Name.Function: Style(color="green"), | |
71 | Name.Namespace: Style(color="cyan", underline=True), | |
72 | Name.Class: Style(color="green", underline=True), | |
73 | Name.Exception: Style(color="cyan"), | |
74 | Name.Decorator: Style(color="magenta", bold=True), | |
75 | Name.Variable: Style(color="red"), | |
76 | Name.Constant: Style(color="red"), | |
77 | Name.Attribute: Style(color="cyan"), | |
78 | Name.Tag: Style(color="bright_blue"), | |
79 | String: Style(color="yellow"), | |
80 | Number: Style(color="blue"), | |
81 | Generic.Deleted: Style(color="bright_red"), | |
82 | Generic.Inserted: Style(color="green"), | |
83 | Generic.Heading: Style(bold=True), | |
84 | Generic.Subheading: Style(color="magenta", bold=True), | |
85 | Generic.Prompt: Style(bold=True), | |
86 | Generic.Error: Style(color="bright_red"), | |
87 | Error: Style(color="red", underline=True), | |
88 | } | |
89 | ||
90 | ANSI_DARK: Dict[TokenType, Style] = { | |
91 | Token: Style(), | |
92 | Whitespace: Style(color="bright_black"), | |
93 | Comment: Style(dim=True), | |
94 | Comment.Preproc: Style(color="bright_cyan"), | |
95 | Keyword: Style(color="bright_blue"), | |
96 | Keyword.Type: Style(color="bright_cyan"), | |
97 | Operator.Word: Style(color="bright_magenta"), | |
98 | Name.Builtin: Style(color="bright_cyan"), | |
99 | Name.Function: Style(color="bright_green"), | |
100 | Name.Namespace: Style(color="bright_cyan", underline=True), | |
101 | Name.Class: Style(color="bright_green", underline=True), | |
102 | Name.Exception: Style(color="bright_cyan"), | |
103 | Name.Decorator: Style(color="bright_magenta", bold=True), | |
104 | Name.Variable: Style(color="bright_red"), | |
105 | Name.Constant: Style(color="bright_red"), | |
106 | Name.Attribute: Style(color="bright_cyan"), | |
107 | Name.Tag: Style(color="bright_blue"), | |
108 | String: Style(color="yellow"), | |
109 | Number: Style(color="bright_blue"), | |
110 | Generic.Deleted: Style(color="bright_red"), | |
111 | Generic.Inserted: Style(color="bright_green"), | |
112 | Generic.Heading: Style(bold=True), | |
113 | Generic.Subheading: Style(color="bright_magenta", bold=True), | |
114 | Generic.Prompt: Style(bold=True), | |
115 | Generic.Error: Style(color="bright_red"), | |
116 | Error: Style(color="red", underline=True), | |
117 | } | |
118 | ||
119 | RICH_SYNTAX_THEMES = {"ansi_light": ANSI_LIGHT, "ansi_dark": ANSI_DARK} | |
120 | NUMBERS_COLUMN_DEFAULT_PADDING = 2 | |
121 | ||
122 | ||
123 | class SyntaxTheme(ABC): | |
124 | """Base class for a syntax theme.""" | |
125 | ||
126 | @abstractmethod | |
127 | def get_style_for_token(self, token_type: TokenType) -> Style: | |
128 | """Get a style for a given Pygments token.""" | |
129 | raise NotImplementedError # pragma: no cover | |
130 | ||
131 | @abstractmethod | |
132 | def get_background_style(self) -> Style: | |
133 | """Get the background color.""" | |
134 | raise NotImplementedError # pragma: no cover | |
135 | ||
136 | ||
137 | class PygmentsSyntaxTheme(SyntaxTheme): | |
138 | """Syntax theme that delegates to Pygments theme.""" | |
139 | ||
140 | def __init__(self, theme: Union[str, Type[PygmentsStyle]]) -> None: | |
141 | self._style_cache: Dict[TokenType, Style] = {} | |
142 | if isinstance(theme, str): | |
143 | try: | |
144 | self._pygments_style_class = get_style_by_name(theme) | |
145 | except ClassNotFound: | |
146 | self._pygments_style_class = get_style_by_name("default") | |
147 | else: | |
148 | self._pygments_style_class = theme | |
149 | ||
150 | self._background_color = self._pygments_style_class.background_color | |
151 | self._background_style = Style(bgcolor=self._background_color) | |
152 | ||
153 | def get_style_for_token(self, token_type: TokenType) -> Style: | |
154 | """Get a style from a Pygments class.""" | |
155 | try: | |
156 | return self._style_cache[token_type] | |
157 | except KeyError: | |
158 | try: | |
159 | pygments_style = self._pygments_style_class.style_for_token(token_type) | |
160 | except KeyError: | |
161 | style = Style.null() | |
162 | else: | |
163 | color = pygments_style["color"] | |
164 | bgcolor = pygments_style["bgcolor"] | |
165 | style = Style( | |
166 | color="#" + color if color else "#000000", | |
167 | bgcolor="#" + bgcolor if bgcolor else self._background_color, | |
168 | bold=pygments_style["bold"], | |
169 | italic=pygments_style["italic"], | |
170 | underline=pygments_style["underline"], | |
171 | ) | |
172 | self._style_cache[token_type] = style | |
173 | return style | |
174 | ||
175 | def get_background_style(self) -> Style: | |
176 | return self._background_style | |
177 | ||
178 | ||
179 | class ANSISyntaxTheme(SyntaxTheme): | |
180 | """Syntax theme to use standard colors.""" | |
181 | ||
182 | def __init__(self, style_map: Dict[TokenType, Style]) -> None: | |
183 | self.style_map = style_map | |
184 | self._missing_style = Style.null() | |
185 | self._background_style = Style.null() | |
186 | self._style_cache: Dict[TokenType, Style] = {} | |
187 | ||
188 | def get_style_for_token(self, token_type: TokenType) -> Style: | |
189 | """Look up style in the style map.""" | |
190 | try: | |
191 | return self._style_cache[token_type] | |
192 | except KeyError: | |
193 | # Styles form a hierarchy | |
194 | # We need to go from most to least specific | |
195 | # e.g. ("foo", "bar", "baz") to ("foo", "bar") to ("foo",) | |
196 | get_style = self.style_map.get | |
197 | token = tuple(token_type) | |
198 | style = self._missing_style | |
199 | while token: | |
200 | _style = get_style(token) | |
201 | if _style is not None: | |
202 | style = _style | |
203 | break | |
204 | token = token[:-1] | |
205 | self._style_cache[token_type] = style | |
206 | return style | |
207 | ||
208 | def get_background_style(self) -> Style: | |
209 | return self._background_style | |
210 | ||
211 | ||
212 | SyntaxPosition = Tuple[int, int] | |
213 | ||
214 | ||
215 | class _SyntaxHighlightRange(NamedTuple): | |
216 | """ | |
217 | A range to highlight in a Syntax object. | |
218 | `start` and `end` are 2-integers tuples, where the first integer is the line number | |
219 | (starting from 1) and the second integer is the column index (starting from 0). | |
220 | """ | |
221 | ||
222 | style: StyleType | |
223 | start: SyntaxPosition | |
224 | end: SyntaxPosition | |
225 | ||
226 | ||
227 | class Syntax(JupyterMixin): | |
228 | """Construct a Syntax object to render syntax highlighted code. | |
229 | ||
230 | Args: | |
231 | code (str): Code to highlight. | |
232 | lexer (Lexer | str): Lexer to use (see https://pygments.org/docs/lexers/) | |
233 | theme (str, optional): Color theme, aka Pygments style (see https://pygments.org/docs/styles/#getting-a-list-of-available-styles). Defaults to "monokai". | |
234 | dedent (bool, optional): Enable stripping of initial whitespace. Defaults to False. | |
235 | line_numbers (bool, optional): Enable rendering of line numbers. Defaults to False. | |
236 | start_line (int, optional): Starting number for line numbers. Defaults to 1. | |
237 | line_range (Tuple[int | None, int | None], optional): If given should be a tuple of the start and end line to render. | |
238 | A value of None in the tuple indicates the range is open in that direction. | |
239 | highlight_lines (Set[int]): A set of line numbers to highlight. | |
240 | code_width: Width of code to render (not including line numbers), or ``None`` to use all available width. | |
241 | tab_size (int, optional): Size of tabs. Defaults to 4. | |
242 | word_wrap (bool, optional): Enable word wrapping. | |
243 | background_color (str, optional): Optional background color, or None to use theme color. Defaults to None. | |
244 | indent_guides (bool, optional): Show indent guides. Defaults to False. | |
245 | padding (PaddingDimensions): Padding to apply around the syntax. Defaults to 0 (no padding). | |
246 | """ | |
247 | ||
248 | _pygments_style_class: Type[PygmentsStyle] | |
249 | _theme: SyntaxTheme | |
250 | ||
251 | @classmethod | |
252 | def get_theme(cls, name: Union[str, SyntaxTheme]) -> SyntaxTheme: | |
253 | """Get a syntax theme instance.""" | |
254 | if isinstance(name, SyntaxTheme): | |
255 | return name | |
256 | theme: SyntaxTheme | |
257 | if name in RICH_SYNTAX_THEMES: | |
258 | theme = ANSISyntaxTheme(RICH_SYNTAX_THEMES[name]) | |
259 | else: | |
260 | theme = PygmentsSyntaxTheme(name) | |
261 | return theme | |
262 | ||
263 | def __init__( | |
264 | self, | |
265 | code: str, | |
266 | lexer: Union[Lexer, str], | |
267 | *, | |
268 | theme: Union[str, SyntaxTheme] = DEFAULT_THEME, | |
269 | dedent: bool = False, | |
270 | line_numbers: bool = False, | |
271 | start_line: int = 1, | |
272 | line_range: Optional[Tuple[Optional[int], Optional[int]]] = None, | |
273 | highlight_lines: Optional[Set[int]] = None, | |
274 | code_width: Optional[int] = None, | |
275 | tab_size: int = 4, | |
276 | word_wrap: bool = False, | |
277 | background_color: Optional[str] = None, | |
278 | indent_guides: bool = False, | |
279 | padding: PaddingDimensions = 0, | |
280 | ) -> None: | |
281 | self.code = code | |
282 | self._lexer = lexer | |
283 | self.dedent = dedent | |
284 | self.line_numbers = line_numbers | |
285 | self.start_line = start_line | |
286 | self.line_range = line_range | |
287 | self.highlight_lines = highlight_lines or set() | |
288 | self.code_width = code_width | |
289 | self.tab_size = tab_size | |
290 | self.word_wrap = word_wrap | |
291 | self.background_color = background_color | |
292 | self.background_style = ( | |
293 | Style(bgcolor=background_color) if background_color else Style() | |
294 | ) | |
295 | self.indent_guides = indent_guides | |
296 | self.padding = padding | |
297 | ||
298 | self._theme = self.get_theme(theme) | |
299 | self._stylized_ranges: List[_SyntaxHighlightRange] = [] | |
300 | ||
301 | @classmethod | |
302 | def from_path( | |
303 | cls, | |
304 | path: str, | |
305 | encoding: str = "utf-8", | |
306 | lexer: Optional[Union[Lexer, str]] = None, | |
307 | theme: Union[str, SyntaxTheme] = DEFAULT_THEME, | |
308 | dedent: bool = False, | |
309 | line_numbers: bool = False, | |
310 | line_range: Optional[Tuple[int, int]] = None, | |
311 | start_line: int = 1, | |
312 | highlight_lines: Optional[Set[int]] = None, | |
313 | code_width: Optional[int] = None, | |
314 | tab_size: int = 4, | |
315 | word_wrap: bool = False, | |
316 | background_color: Optional[str] = None, | |
317 | indent_guides: bool = False, | |
318 | padding: PaddingDimensions = 0, | |
319 | ) -> "Syntax": | |
320 | """Construct a Syntax object from a file. | |
321 | ||
322 | Args: | |
323 | path (str): Path to file to highlight. | |
324 | encoding (str): Encoding of file. | |
325 | lexer (str | Lexer, optional): Lexer to use. If None, lexer will be auto-detected from path/file content. | |
326 | theme (str, optional): Color theme, aka Pygments style (see https://pygments.org/docs/styles/#getting-a-list-of-available-styles). Defaults to "emacs". | |
327 | dedent (bool, optional): Enable stripping of initial whitespace. Defaults to True. | |
328 | line_numbers (bool, optional): Enable rendering of line numbers. Defaults to False. | |
329 | start_line (int, optional): Starting number for line numbers. Defaults to 1. | |
330 | line_range (Tuple[int, int], optional): If given should be a tuple of the start and end line to render. | |
331 | highlight_lines (Set[int]): A set of line numbers to highlight. | |
332 | code_width: Width of code to render (not including line numbers), or ``None`` to use all available width. | |
333 | tab_size (int, optional): Size of tabs. Defaults to 4. | |
334 | word_wrap (bool, optional): Enable word wrapping of code. | |
335 | background_color (str, optional): Optional background color, or None to use theme color. Defaults to None. | |
336 | indent_guides (bool, optional): Show indent guides. Defaults to False. | |
337 | padding (PaddingDimensions): Padding to apply around the syntax. Defaults to 0 (no padding). | |
338 | ||
339 | Returns: | |
340 | [Syntax]: A Syntax object that may be printed to the console | |
341 | """ | |
342 | code = Path(path).read_text(encoding=encoding) | |
343 | ||
344 | if not lexer: | |
345 | lexer = cls.guess_lexer(path, code=code) | |
346 | ||
347 | return cls( | |
348 | code, | |
349 | lexer, | |
350 | theme=theme, | |
351 | dedent=dedent, | |
352 | line_numbers=line_numbers, | |
353 | line_range=line_range, | |
354 | start_line=start_line, | |
355 | highlight_lines=highlight_lines, | |
356 | code_width=code_width, | |
357 | tab_size=tab_size, | |
358 | word_wrap=word_wrap, | |
359 | background_color=background_color, | |
360 | indent_guides=indent_guides, | |
361 | padding=padding, | |
362 | ) | |
363 | ||
364 | @classmethod | |
365 | def guess_lexer(cls, path: str, code: Optional[str] = None) -> str: | |
366 | """Guess the alias of the Pygments lexer to use based on a path and an optional string of code. | |
367 | If code is supplied, it will use a combination of the code and the filename to determine the | |
368 | best lexer to use. For example, if the file is ``index.html`` and the file contains Django | |
369 | templating syntax, then "html+django" will be returned. If the file is ``index.html``, and no | |
370 | templating language is used, the "html" lexer will be used. If no string of code | |
371 | is supplied, the lexer will be chosen based on the file extension.. | |
372 | ||
373 | Args: | |
374 | path (AnyStr): The path to the file containing the code you wish to know the lexer for. | |
375 | code (str, optional): Optional string of code that will be used as a fallback if no lexer | |
376 | is found for the supplied path. | |
377 | ||
378 | Returns: | |
379 | str: The name of the Pygments lexer that best matches the supplied path/code. | |
380 | """ | |
381 | lexer: Optional[Lexer] = None | |
382 | lexer_name = "default" | |
383 | if code: | |
384 | try: | |
385 | lexer = guess_lexer_for_filename(path, code) | |
386 | except ClassNotFound: | |
387 | pass | |
388 | ||
389 | if not lexer: | |
390 | try: | |
391 | _, ext = os.path.splitext(path) | |
392 | if ext: | |
393 | extension = ext.lstrip(".").lower() | |
394 | lexer = get_lexer_by_name(extension) | |
395 | except ClassNotFound: | |
396 | pass | |
397 | ||
398 | if lexer: | |
399 | if lexer.aliases: | |
400 | lexer_name = lexer.aliases[0] | |
401 | else: | |
402 | lexer_name = lexer.name | |
403 | ||
404 | return lexer_name | |
405 | ||
406 | def _get_base_style(self) -> Style: | |
407 | """Get the base style.""" | |
408 | default_style = self._theme.get_background_style() + self.background_style | |
409 | return default_style | |
410 | ||
411 | def _get_token_color(self, token_type: TokenType) -> Optional[Color]: | |
412 | """Get a color (if any) for the given token. | |
413 | ||
414 | Args: | |
415 | token_type (TokenType): A token type tuple from Pygments. | |
416 | ||
417 | Returns: | |
418 | Optional[Color]: Color from theme, or None for no color. | |
419 | """ | |
420 | style = self._theme.get_style_for_token(token_type) | |
421 | return style.color | |
422 | ||
423 | @property | |
424 | def lexer(self) -> Optional[Lexer]: | |
425 | """The lexer for this syntax, or None if no lexer was found. | |
426 | ||
427 | Tries to find the lexer by name if a string was passed to the constructor. | |
428 | """ | |
429 | ||
430 | if isinstance(self._lexer, Lexer): | |
431 | return self._lexer | |
432 | try: | |
433 | return get_lexer_by_name( | |
434 | self._lexer, | |
435 | stripnl=False, | |
436 | ensurenl=True, | |
437 | tabsize=self.tab_size, | |
438 | ) | |
439 | except ClassNotFound: | |
440 | return None | |
441 | ||
442 | def highlight( | |
443 | self, | |
444 | code: str, | |
445 | line_range: Optional[Tuple[Optional[int], Optional[int]]] = None, | |
446 | ) -> Text: | |
447 | """Highlight code and return a Text instance. | |
448 | ||
449 | Args: | |
450 | code (str): Code to highlight. | |
451 | line_range(Tuple[int, int], optional): Optional line range to highlight. | |
452 | ||
453 | Returns: | |
454 | Text: A text instance containing highlighted syntax. | |
455 | """ | |
456 | ||
457 | base_style = self._get_base_style() | |
458 | justify: JustifyMethod = ( | |
459 | "default" if base_style.transparent_background else "left" | |
460 | ) | |
461 | ||
462 | text = Text( | |
463 | justify=justify, | |
464 | style=base_style, | |
465 | tab_size=self.tab_size, | |
466 | no_wrap=not self.word_wrap, | |
467 | ) | |
468 | _get_theme_style = self._theme.get_style_for_token | |
469 | ||
470 | lexer = self.lexer | |
471 | ||
472 | if lexer is None: | |
473 | text.append(code) | |
474 | else: | |
475 | if line_range: | |
476 | # More complicated path to only stylize a portion of the code | |
477 | # This speeds up further operations as there are less spans to process | |
478 | line_start, line_end = line_range | |
479 | ||
480 | def line_tokenize() -> Iterable[Tuple[Any, str]]: | |
481 | """Split tokens to one per line.""" | |
482 | assert lexer # required to make MyPy happy - we know lexer is not None at this point | |
483 | ||
484 | for token_type, token in lexer.get_tokens(code): | |
485 | while token: | |
486 | line_token, new_line, token = token.partition("\n") | |
487 | yield token_type, line_token + new_line | |
488 | ||
489 | def tokens_to_spans() -> Iterable[Tuple[str, Optional[Style]]]: | |
490 | """Convert tokens to spans.""" | |
491 | tokens = iter(line_tokenize()) | |
492 | line_no = 0 | |
493 | _line_start = line_start - 1 if line_start else 0 | |
494 | ||
495 | # Skip over tokens until line start | |
496 | while line_no < _line_start: | |
497 | try: | |
498 | _token_type, token = next(tokens) | |
499 | except StopIteration: | |
500 | break | |
501 | yield (token, None) | |
502 | if token.endswith("\n"): | |
503 | line_no += 1 | |
504 | # Generate spans until line end | |
505 | for token_type, token in tokens: | |
506 | yield (token, _get_theme_style(token_type)) | |
507 | if token.endswith("\n"): | |
508 | line_no += 1 | |
509 | if line_end and line_no >= line_end: | |
510 | break | |
511 | ||
512 | text.append_tokens(tokens_to_spans()) | |
513 | ||
514 | else: | |
515 | text.append_tokens( | |
516 | (token, _get_theme_style(token_type)) | |
517 | for token_type, token in lexer.get_tokens(code) | |
518 | ) | |
519 | if self.background_color is not None: | |
520 | text.stylize(f"on {self.background_color}") | |
521 | ||
522 | if self._stylized_ranges: | |
523 | self._apply_stylized_ranges(text) | |
524 | ||
525 | return text | |
526 | ||
527 | def stylize_range( | |
528 | self, style: StyleType, start: SyntaxPosition, end: SyntaxPosition | |
529 | ) -> None: | |
530 | """ | |
531 | Adds a custom style on a part of the code, that will be applied to the syntax display when it's rendered. | |
532 | Line numbers are 1-based, while column indexes are 0-based. | |
533 | ||
534 | Args: | |
535 | style (StyleType): The style to apply. | |
536 | start (Tuple[int, int]): The start of the range, in the form `[line number, column index]`. | |
537 | end (Tuple[int, int]): The end of the range, in the form `[line number, column index]`. | |
538 | """ | |
539 | self._stylized_ranges.append(_SyntaxHighlightRange(style, start, end)) | |
540 | ||
541 | def _get_line_numbers_color(self, blend: float = 0.3) -> Color: | |
542 | background_style = self._theme.get_background_style() + self.background_style | |
543 | background_color = background_style.bgcolor | |
544 | if background_color is None or background_color.is_system_defined: | |
545 | return Color.default() | |
546 | foreground_color = self._get_token_color(Token.Text) | |
547 | if foreground_color is None or foreground_color.is_system_defined: | |
548 | return foreground_color or Color.default() | |
549 | new_color = blend_rgb( | |
550 | background_color.get_truecolor(), | |
551 | foreground_color.get_truecolor(), | |
552 | cross_fade=blend, | |
553 | ) | |
554 | return Color.from_triplet(new_color) | |
555 | ||
556 | @property | |
557 | def _numbers_column_width(self) -> int: | |
558 | """Get the number of characters used to render the numbers column.""" | |
559 | column_width = 0 | |
560 | if self.line_numbers: | |
561 | column_width = ( | |
562 | len(str(self.start_line + self.code.count("\n"))) | |
563 | + NUMBERS_COLUMN_DEFAULT_PADDING | |
564 | ) | |
565 | return column_width | |
566 | ||
567 | def _get_number_styles(self, console: Console) -> Tuple[Style, Style, Style]: | |
568 | """Get background, number, and highlight styles for line numbers.""" | |
569 | background_style = self._get_base_style() | |
570 | if background_style.transparent_background: | |
571 | return Style.null(), Style(dim=True), Style.null() | |
572 | if console.color_system in ("256", "truecolor"): | |
573 | number_style = Style.chain( | |
574 | background_style, | |
575 | self._theme.get_style_for_token(Token.Text), | |
576 | Style(color=self._get_line_numbers_color()), | |
577 | self.background_style, | |
578 | ) | |
579 | highlight_number_style = Style.chain( | |
580 | background_style, | |
581 | self._theme.get_style_for_token(Token.Text), | |
582 | Style(bold=True, color=self._get_line_numbers_color(0.9)), | |
583 | self.background_style, | |
584 | ) | |
585 | else: | |
586 | number_style = background_style + Style(dim=True) | |
587 | highlight_number_style = background_style + Style(dim=False) | |
588 | return background_style, number_style, highlight_number_style | |
589 | ||
590 | def __rich_measure__( | |
591 | self, console: "Console", options: "ConsoleOptions" | |
592 | ) -> "Measurement": | |
593 | _, right, _, left = Padding.unpack(self.padding) | |
594 | padding = left + right | |
595 | if self.code_width is not None: | |
596 | width = self.code_width + self._numbers_column_width + padding + 1 | |
597 | return Measurement(self._numbers_column_width, width) | |
598 | lines = self.code.splitlines() | |
599 | width = ( | |
600 | self._numbers_column_width | |
601 | + padding | |
602 | + (max(cell_len(line) for line in lines) if lines else 0) | |
603 | ) | |
604 | if self.line_numbers: | |
605 | width += 1 | |
606 | return Measurement(self._numbers_column_width, width) | |
607 | ||
608 | def __rich_console__( | |
609 | self, console: Console, options: ConsoleOptions | |
610 | ) -> RenderResult: | |
611 | segments = Segments(self._get_syntax(console, options)) | |
612 | if self.padding: | |
613 | yield Padding( | |
614 | segments, style=self._theme.get_background_style(), pad=self.padding | |
615 | ) | |
616 | else: | |
617 | yield segments | |
618 | ||
619 | def _get_syntax( | |
620 | self, | |
621 | console: Console, | |
622 | options: ConsoleOptions, | |
623 | ) -> Iterable[Segment]: | |
624 | """ | |
625 | Get the Segments for the Syntax object, excluding any vertical/horizontal padding | |
626 | """ | |
627 | transparent_background = self._get_base_style().transparent_background | |
628 | code_width = ( | |
629 | ( | |
630 | (options.max_width - self._numbers_column_width - 1) | |
631 | if self.line_numbers | |
632 | else options.max_width | |
633 | ) | |
634 | if self.code_width is None | |
635 | else self.code_width | |
636 | ) | |
637 | ||
638 | ends_on_nl, processed_code = self._process_code(self.code) | |
639 | text = self.highlight(processed_code, self.line_range) | |
640 | ||
641 | if not self.line_numbers and not self.word_wrap and not self.line_range: | |
642 | if not ends_on_nl: | |
643 | text.remove_suffix("\n") | |
644 | # Simple case of just rendering text | |
645 | style = ( | |
646 | self._get_base_style() | |
647 | + self._theme.get_style_for_token(Comment) | |
648 | + Style(dim=True) | |
649 | + self.background_style | |
650 | ) | |
651 | if self.indent_guides and not options.ascii_only: | |
652 | text = text.with_indent_guides(self.tab_size, style=style) | |
653 | text.overflow = "crop" | |
654 | if style.transparent_background: | |
655 | yield from console.render( | |
656 | text, options=options.update(width=code_width) | |
657 | ) | |
658 | else: | |
659 | syntax_lines = console.render_lines( | |
660 | text, | |
661 | options.update(width=code_width, height=None, justify="left"), | |
662 | style=self.background_style, | |
663 | pad=True, | |
664 | new_lines=True, | |
665 | ) | |
666 | for syntax_line in syntax_lines: | |
667 | yield from syntax_line | |
668 | return | |
669 | ||
670 | start_line, end_line = self.line_range or (None, None) | |
671 | line_offset = 0 | |
672 | if start_line: | |
673 | line_offset = max(0, start_line - 1) | |
674 | lines: Union[List[Text], Lines] = text.split("\n", allow_blank=ends_on_nl) | |
675 | if self.line_range: | |
676 | if line_offset > len(lines): | |
677 | return | |
678 | lines = lines[line_offset:end_line] | |
679 | ||
680 | if self.indent_guides and not options.ascii_only: | |
681 | style = ( | |
682 | self._get_base_style() | |
683 | + self._theme.get_style_for_token(Comment) | |
684 | + Style(dim=True) | |
685 | + self.background_style | |
686 | ) | |
687 | lines = ( | |
688 | Text("\n") | |
689 | .join(lines) | |
690 | .with_indent_guides(self.tab_size, style=style + Style(italic=False)) | |
691 | .split("\n", allow_blank=True) | |
692 | ) | |
693 | ||
694 | numbers_column_width = self._numbers_column_width | |
695 | render_options = options.update(width=code_width) | |
696 | ||
697 | highlight_line = self.highlight_lines.__contains__ | |
698 | _Segment = Segment | |
699 | new_line = _Segment("\n") | |
700 | ||
701 | line_pointer = "> " if options.legacy_windows else "❱ " | |
702 | ||
703 | ( | |
704 | background_style, | |
705 | number_style, | |
706 | highlight_number_style, | |
707 | ) = self._get_number_styles(console) | |
708 | ||
709 | for line_no, line in enumerate(lines, self.start_line + line_offset): | |
710 | if self.word_wrap: | |
711 | wrapped_lines = console.render_lines( | |
712 | line, | |
713 | render_options.update(height=None, justify="left"), | |
714 | style=background_style, | |
715 | pad=not transparent_background, | |
716 | ) | |
717 | else: | |
718 | segments = list(line.render(console, end="")) | |
719 | if options.no_wrap: | |
720 | wrapped_lines = [segments] | |
721 | else: | |
722 | wrapped_lines = [ | |
723 | _Segment.adjust_line_length( | |
724 | segments, | |
725 | render_options.max_width, | |
726 | style=background_style, | |
727 | pad=not transparent_background, | |
728 | ) | |
729 | ] | |
730 | ||
731 | if self.line_numbers: | |
732 | wrapped_line_left_pad = _Segment( | |
733 | " " * numbers_column_width + " ", background_style | |
734 | ) | |
735 | for first, wrapped_line in loop_first(wrapped_lines): | |
736 | if first: | |
737 | line_column = str(line_no).rjust(numbers_column_width - 2) + " " | |
738 | if highlight_line(line_no): | |
739 | yield _Segment(line_pointer, Style(color="red")) | |
740 | yield _Segment(line_column, highlight_number_style) | |
741 | else: | |
742 | yield _Segment(" ", highlight_number_style) | |
743 | yield _Segment(line_column, number_style) | |
744 | else: | |
745 | yield wrapped_line_left_pad | |
746 | yield from wrapped_line | |
747 | yield new_line | |
748 | else: | |
749 | for wrapped_line in wrapped_lines: | |
750 | yield from wrapped_line | |
751 | yield new_line | |
752 | ||
753 | def _apply_stylized_ranges(self, text: Text) -> None: | |
754 | """ | |
755 | Apply stylized ranges to a text instance, | |
756 | using the given code to determine the right portion to apply the style to. | |
757 | ||
758 | Args: | |
759 | text (Text): Text instance to apply the style to. | |
760 | """ | |
761 | code = text.plain | |
762 | newlines_offsets = [ | |
763 | # Let's add outer boundaries at each side of the list: | |
764 | 0, | |
765 | # N.B. using "\n" here is much faster than using metacharacters such as "^" or "\Z": | |
766 | *[ | |
767 | match.start() + 1 | |
768 | for match in re.finditer("\n", code, flags=re.MULTILINE) | |
769 | ], | |
770 | len(code) + 1, | |
771 | ] | |
772 | ||
773 | for stylized_range in self._stylized_ranges: | |
774 | start = _get_code_index_for_syntax_position( | |
775 | newlines_offsets, stylized_range.start | |
776 | ) | |
777 | end = _get_code_index_for_syntax_position( | |
778 | newlines_offsets, stylized_range.end | |
779 | ) | |
780 | if start is not None and end is not None: | |
781 | text.stylize(stylized_range.style, start, end) | |
782 | ||
783 | def _process_code(self, code: str) -> Tuple[bool, str]: | |
784 | """ | |
785 | Applies various processing to a raw code string | |
786 | (normalises it so it always ends with a line return, dedents it if necessary, etc.) | |
787 | ||
788 | Args: | |
789 | code (str): The raw code string to process | |
790 | ||
791 | Returns: | |
792 | Tuple[bool, str]: the boolean indicates whether the raw code ends with a line return, | |
793 | while the string is the processed code. | |
794 | """ | |
795 | ends_on_nl = code.endswith("\n") | |
796 | processed_code = code if ends_on_nl else code + "\n" | |
797 | processed_code = ( | |
798 | textwrap.dedent(processed_code) if self.dedent else processed_code | |
799 | ) | |
800 | processed_code = processed_code.expandtabs(self.tab_size) | |
801 | return ends_on_nl, processed_code | |
802 | ||
803 | ||
804 | def _get_code_index_for_syntax_position( | |
805 | newlines_offsets: Sequence[int], position: SyntaxPosition | |
806 | ) -> Optional[int]: | |
807 | """ | |
808 | Returns the index of the code string for the given positions. | |
809 | ||
810 | Args: | |
811 | newlines_offsets (Sequence[int]): The offset of each newline character found in the code snippet. | |
812 | position (SyntaxPosition): The position to search for. | |
813 | ||
814 | Returns: | |
815 | Optional[int]: The index of the code string for this position, or `None` | |
816 | if the given position's line number is out of range (if it's the column that is out of range | |
817 | we silently clamp its value so that it reaches the end of the line) | |
818 | """ | |
819 | lines_count = len(newlines_offsets) | |
820 | ||
821 | line_number, column_index = position | |
822 | if line_number > lines_count or len(newlines_offsets) < (line_number + 1): | |
823 | return None # `line_number` is out of range | |
824 | line_index = line_number - 1 | |
825 | line_length = newlines_offsets[line_index + 1] - newlines_offsets[line_index] - 1 | |
826 | # If `column_index` is out of range: let's silently clamp it: | |
827 | column_index = min(line_length, column_index) | |
828 | return newlines_offsets[line_index] + column_index | |
829 | ||
830 | ||
831 | if __name__ == "__main__": # pragma: no cover | |
832 | import argparse | |
833 | import sys | |
834 | ||
835 | parser = argparse.ArgumentParser( | |
836 | description="Render syntax to the console with Rich" | |
837 | ) | |
838 | parser.add_argument( | |
839 | "path", | |
840 | metavar="PATH", | |
841 | help="path to file, or - for stdin", | |
842 | ) | |
843 | parser.add_argument( | |
844 | "-c", | |
845 | "--force-color", | |
846 | dest="force_color", | |
847 | action="store_true", | |
848 | default=None, | |
849 | help="force color for non-terminals", | |
850 | ) | |
851 | parser.add_argument( | |
852 | "-i", | |
853 | "--indent-guides", | |
854 | dest="indent_guides", | |
855 | action="store_true", | |
856 | default=False, | |
857 | help="display indent guides", | |
858 | ) | |
859 | parser.add_argument( | |
860 | "-l", | |
861 | "--line-numbers", | |
862 | dest="line_numbers", | |
863 | action="store_true", | |
864 | help="render line numbers", | |
865 | ) | |
866 | parser.add_argument( | |
867 | "-w", | |
868 | "--width", | |
869 | type=int, | |
870 | dest="width", | |
871 | default=None, | |
872 | help="width of output (default will auto-detect)", | |
873 | ) | |
874 | parser.add_argument( | |
875 | "-r", | |
876 | "--wrap", | |
877 | dest="word_wrap", | |
878 | action="store_true", | |
879 | default=False, | |
880 | help="word wrap long lines", | |
881 | ) | |
882 | parser.add_argument( | |
883 | "-s", | |
884 | "--soft-wrap", | |
885 | action="store_true", | |
886 | dest="soft_wrap", | |
887 | default=False, | |
888 | help="enable soft wrapping mode", | |
889 | ) | |
890 | parser.add_argument( | |
891 | "-t", "--theme", dest="theme", default="monokai", help="pygments theme" | |
892 | ) | |
893 | parser.add_argument( | |
894 | "-b", | |
895 | "--background-color", | |
896 | dest="background_color", | |
897 | default=None, | |
898 | help="Override background color", | |
899 | ) | |
900 | parser.add_argument( | |
901 | "-x", | |
902 | "--lexer", | |
903 | default=None, | |
904 | dest="lexer_name", | |
905 | help="Lexer name", | |
906 | ) | |
907 | parser.add_argument( | |
908 | "-p", "--padding", type=int, default=0, dest="padding", help="Padding" | |
909 | ) | |
910 | parser.add_argument( | |
911 | "--highlight-line", | |
912 | type=int, | |
913 | default=None, | |
914 | dest="highlight_line", | |
915 | help="The line number (not index!) to highlight", | |
916 | ) | |
917 | args = parser.parse_args() | |
918 | ||
919 | from pip._vendor.rich.console import Console | |
920 | ||
921 | console = Console(force_terminal=args.force_color, width=args.width) | |
922 | ||
923 | if args.path == "-": | |
924 | code = sys.stdin.read() | |
925 | syntax = Syntax( | |
926 | code=code, | |
927 | lexer=args.lexer_name, | |
928 | line_numbers=args.line_numbers, | |
929 | word_wrap=args.word_wrap, | |
930 | theme=args.theme, | |
931 | background_color=args.background_color, | |
932 | indent_guides=args.indent_guides, | |
933 | padding=args.padding, | |
934 | highlight_lines={args.highlight_line}, | |
935 | ) | |
936 | else: | |
937 | syntax = Syntax.from_path( | |
938 | args.path, | |
939 | lexer=args.lexer_name, | |
940 | line_numbers=args.line_numbers, | |
941 | word_wrap=args.word_wrap, | |
942 | theme=args.theme, | |
943 | background_color=args.background_color, | |
944 | indent_guides=args.indent_guides, | |
945 | padding=args.padding, | |
946 | highlight_lines={args.highlight_line}, | |
947 | ) | |
948 | console.print(syntax, soft_wrap=args.soft_wrap) |