├── .activate.sh ├── .coveragerc ├── .deactivate.sh ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── mypy.ini ├── pygments_ansi_color ├── __init__.py └── py.typed ├── requirements-dev.txt ├── setup.py ├── tests ├── __init__.py └── pygments_ansi_color_test.py └── tox.ini /.activate.sh: -------------------------------------------------------------------------------- 1 | venv/bin/activate -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = 4 | . 5 | omit = 6 | .tox/* 7 | /usr/* 8 | setup.py 9 | 10 | [report] 11 | show_missing = True 12 | 13 | exclude_lines = 14 | # Have to re-enable the standard pragma 15 | \#\s*pragma: no cover 16 | 17 | # Don't complain if tests don't hit defensive assertion code: 18 | ^\s*raise AssertionError\b 19 | ^\s*raise NotImplementedError\b 20 | ^\s*return NotImplemented\b 21 | ^\s*raise$ 22 | 23 | # Don't complain if non-runnable code isn't run: 24 | ^if __name__ == ['"]__main__['"]:$ 25 | 26 | [html] 27 | directory = coverage-html 28 | 29 | # vim:ft=dosini 30 | -------------------------------------------------------------------------------- /.deactivate.sh: -------------------------------------------------------------------------------- 1 | deactivate 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: [main, test-me-*] 6 | tags: '*' 7 | pull_request: 8 | 9 | jobs: 10 | main-windows: 11 | uses: asottile/workflows/.github/workflows/tox.yml@v1.8.1 12 | with: 13 | env: '["py39"]' 14 | os: windows-latest 15 | main-linux: 16 | uses: asottile/workflows/.github/workflows/tox.yml@v1.8.1 17 | with: 18 | env: '["py39", "py310", "py311", "py312"]' 19 | os: ubuntu-latest 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | /build 3 | /dist 4 | /.cache 5 | /.coverage 6 | /.tox 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-docstring-first 8 | - id: check-merge-conflict 9 | - id: check-yaml 10 | - id: debug-statements 11 | - id: double-quote-string-fixer 12 | - id: name-tests-test 13 | - id: check-added-large-files 14 | - id: check-byte-order-marker 15 | - repo: https://github.com/hhatto/autopep8 16 | rev: v2.3.2 17 | hooks: 18 | - id: autopep8 19 | - repo: https://github.com/PyCQA/flake8 20 | rev: 7.2.0 21 | hooks: 22 | - id: flake8 23 | - repo: https://github.com/asottile/reorder-python-imports 24 | rev: v3.15.0 25 | hooks: 26 | - id: reorder-python-imports 27 | args: [ 28 | '--py39-plus', 29 | '--add-import', 'from __future__ import annotations', 30 | ] 31 | - repo: https://github.com/asottile/pyupgrade 32 | rev: v3.20.0 33 | hooks: 34 | - id: pyupgrade 35 | args: ['--py39-plus'] 36 | - repo: https://github.com/asottile/add-trailing-comma 37 | rev: v3.2.0 38 | hooks: 39 | - id: add-trailing-comma 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Chris Kuehl 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: minimal 2 | minimal: venv 3 | 4 | .PHONY: venv 5 | venv: 6 | tox -e venv 7 | 8 | .PHONY: test 9 | test: 10 | tox 11 | 12 | .PHONY: clean 13 | clean: 14 | find -name '*.pyc' -delete 15 | find -name '__pycache__' -delete 16 | rm -rf .tox 17 | rm -rf venv 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pygments-ansi-color 2 | ------------------- 3 | 4 | [![build status](https://github.com/chriskuehl/pygments-ansi-color/actions/workflows/main.yml/badge.svg)](https://github.com/chriskuehl/pygments-ansi-color/actions/workflows/main.yml) 5 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/chriskuehl/pygments-ansi-color/main.svg)](https://results.pre-commit.ci/latest/github/chriskuehl/pygments-ansi-color/main) 6 | [![PyPI version](https://badge.fury.io/py/pygments-ansi-color.svg)](https://pypi.python.org/pypi/pygments-ansi-color) 7 | 8 | An ANSI color-code highlighting lexer for Pygments. 9 | 10 | ![](https://i.fluffy.cc/nHPkL3gfBtj5Kt4H3RR51T9TJLh6rtv2.png) 11 | 12 | 13 | ### Basic usage 14 | 15 | 1. Install `pygments-ansi-color`: 16 | 17 | ```shell-session 18 | $ pip install pygments-ansi-color 19 | ``` 20 | 21 | 2. `pygments-ansi-color` is not magic (yet?), so you need to [choose an existing 22 | Pygments style](https://pygments.org/styles/), which will be used as a base 23 | for your own style. 24 | 25 | For example, let's choose `pygments.styles.xcode.XcodeStyle`, which looks 26 | great to use. And then we will augment this reference style with 27 | `pygments-ansi-color`'s color tokens thanks to the `color_tokens` function, 28 | to make our final `MyStyle` custom style. 29 | 30 | Here is how the code looks like: 31 | 32 | ```python 33 | from pygments_ansi_color import color_tokens 34 | 35 | class MyStyle(pygments.styles.xcode.XcodeStyle): 36 | styles = dict(pygments.styles.xcode.XcodeStyle.styles) 37 | styles.update(color_tokens()) 38 | ``` 39 | 40 | That's all the custom code you need to integrate with `pygments-ansi-color`. 41 | 42 | 3. Now you can highlight your content with the dedicated ANSI lexer and your 43 | custom style, with the Pygments regular API: 44 | 45 | ```python 46 | import pygments 47 | import pygments.formatters 48 | import pygments.lexers 49 | 50 | lexer = pygments.lexers.get_lexer_by_name('ansi-color') 51 | formatter = pygments.formatters.HtmlFormatter(style=MyStyle) 52 | print(pygments.highlight('your text', lexer, formatter)) 53 | ``` 54 | 55 | ### Design 56 | 57 | We had to configure above a custom Pygments style with the appropriate color 58 | tokens. That's because existing Pygments lexers are built around contextual 59 | tokens (think `Comment` or `Punctuation`) rather than actual colors. 60 | 61 | In the case of ANSI escape sequences, colors have no context beyond the color 62 | themselves; we'd always want a `red` rendered as `red`, regardless of your 63 | particular theme. 64 | 65 | 66 | ### Custom theme 67 | 68 | By default, `pygments-ansi-color` maps ANSI codes to its own set of colors. 69 | They have been carefully crafted for readability, and are [loosely based on the 70 | color scheme used by iTerm2 71 | ](https://github.com/chriskuehl/pygments-ansi-color/pull/27#discussion_r1113790011). 72 | 73 | Default colors are hard-coded by the `pygments_ansi_color.DEFAULT_STYLE` 74 | constant as such: 75 | - ![#000000](https://placehold.co/15/000000/000000) `Black`: `#000000` 76 | - ![#ef2929](https://placehold.co/15/ef2929/ef2929) `Red`: `#ef2929` 77 | - ![#8ae234](https://placehold.co/15/8ae234/8ae234) `Green`: `#8ae234` 78 | - ![#fce94f](https://placehold.co/15/fce94f/fce94f) `Yellow`: `#fce94f` 79 | - ![#3465a4](https://placehold.co/15/3465a4/3465a4) `Blue`: `#3465a4` 80 | - ![#c509c5](https://placehold.co/15/c509c5/c509c5) `Magenta`: `#c509c5` 81 | - ![#34e2e2](https://placehold.co/15/34e2e2/34e2e2) `Cyan`: `#34e2e2` 82 | - ![#f5f5f5](https://placehold.co/15/f5f5f5/f5f5f5) `White`: `#f5f5f5` 83 | - ![#676767](https://placehold.co/15/676767/676767) `BrightBlack`: `#676767` 84 | - ![#ff6d67](https://placehold.co/15/ff6d67/ff6d67) `BrightRed`: `#ff6d67` 85 | - ![#5ff967](https://placehold.co/15/5ff967/5ff967) `BrightGreen`: `#5ff967` 86 | - ![#fefb67](https://placehold.co/15/fefb67/fefb67) `BrightYellow`: `#fefb67` 87 | - ![#6871ff](https://placehold.co/15/6871ff/6871ff) `BrightBlue`: `#6871ff` 88 | - ![#ff76ff](https://placehold.co/15/ff76ff/ff76ff) `BrightMagenta`: `#ff76ff` 89 | - ![#5ffdff](https://placehold.co/15/5ffdff/5ffdff) `BrightCyan`: `#5ffdff` 90 | - ![#feffff](https://placehold.co/15/feffff/feffff) `BrightWhite`: `#feffff` 91 | 92 | Still, you may want to use your own colors, to tweak the rendering to your 93 | background color, or to match your own theme. 94 | 95 | For that you can override each color individually, by passing them as 96 | arguments to the `color_tokens` function: 97 | 98 | ```python 99 | from pygments_ansi_color import color_tokens 100 | 101 | class MyStyle(pygments.styles.xcode.XcodeStyle): 102 | styles = dict(pygments.styles.xcode.XcodeStyle.styles) 103 | styles.update(color_tokens( 104 | fg_colors={'Cyan': '#00ffff', 'BrightCyan': '#00ffff'}, 105 | bg_colors={'BrightWhite': '#000000'}, 106 | )) 107 | ``` 108 | 109 | 110 | ### Used by 111 | 112 | You can see an example [on fluffy][fluffy-example], the project that this lexer 113 | was originally developed for. 114 | 115 | The colors are defined as part of your Pygments style and can be changed. 116 | 117 | 118 | ### Optional: Enable "256 color" support 119 | 120 | This library supports rendering terminal output using [256 color 121 | (8-bit)][256-color] ANSI color codes. However, because of limitations in 122 | Pygments tokens, this is an opt-in feature which requires patching the 123 | formatter you're using. 124 | 125 | The reason this requires patching the Pygments formatter is that Pygments does 126 | not support multiple tokens on a single piece of text, requiring us to 127 | "compose" a single state (which is a tuple of `(bold enabled, fg color, bg 128 | color)`) into a single token like `Color.Bold.FGColor.BGColor`. We then need to 129 | output the styles for this token in the CSS. 130 | 131 | In the default mode where we only support the standard 8 colors (plus 1 for no 132 | color), we need 2 × 9 × 9 - 1 = 161 tokens, which is reasonable to contain in 133 | one CSS file. With 256 colors (plus the standard 8, plus 1 for no color), 134 | though, we'd need 2 × 265 × 265 - 1 = 140,449 tokens defined in CSS. This makes 135 | the CSS too large to be practical. 136 | 137 | To make 256-color support realistic, we patch Pygments' HTML formatter so that 138 | it places a class for each part of the state tuple independently. This means 139 | you need only 1 + 265 + 265 = 531 CSS classes to support all possibilities. 140 | 141 | If you'd like to enable 256-color support, you'll need to do two things: 142 | 143 | 1. When calling `color_tokens`, pass `enable_256color=True`: 144 | 145 | ```python 146 | styles.update(color_tokens(enable_256color=True)) 147 | ``` 148 | 149 | This change is what causes your CSS to have the appropriate classes in it. 150 | 151 | 2. When constructing your formatter, use the `ExtendedColorHtmlFormatterMixin` 152 | mixin, like this: 153 | 154 | ```python 155 | from pygments.formatters import HtmlFormatter 156 | from pygments_ansi_color import ExtendedColorHtmlFormatterMixin 157 | 158 | ... 159 | 160 | class MyFormatter(ExtendedColorHtmlFormatterMixin, HtmlFormatter): 161 | pass 162 | 163 | ... 164 | 165 | formatter = pygments.formatter.HtmlFormatter(style=MyStyle) 166 | ``` 167 | 168 | This change is what causes the rendered HTML to have the right class names. 169 | 170 | Once these two changes have been made, you can use pygments-ansi-color as normal. 171 | 172 | 173 | [fluffy-example]: https://i.fluffy.cc/3Gq7Fg86mv3dX30Qx9LHMWcKMqsQLCtd.html 174 | [256-color]: https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit 175 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | check_untyped_defs = true 3 | disallow_any_generics = true 4 | disallow_incomplete_defs = true 5 | disallow_untyped_defs = true 6 | warn_redundant_casts = true 7 | warn_unused_ignores = true 8 | -------------------------------------------------------------------------------- /pygments_ansi_color/__init__.py: -------------------------------------------------------------------------------- 1 | """Pygments lexer for text containing ANSI color codes.""" 2 | from __future__ import annotations 3 | 4 | import itertools 5 | import re 6 | import typing 7 | 8 | import pygments.lexer 9 | import pygments.token 10 | 11 | 12 | C = pygments.token.Token.C 13 | Color = pygments.token.Token.Color 14 | 15 | 16 | _ansi_code_to_color = { 17 | 0: 'Black', 18 | 1: 'Red', 19 | 2: 'Green', 20 | 3: 'Yellow', 21 | 4: 'Blue', 22 | 5: 'Magenta', 23 | 6: 'Cyan', 24 | 7: 'White', 25 | 60: 'BrightBlack', 26 | 61: 'BrightRed', 27 | 62: 'BrightGreen', 28 | 63: 'BrightYellow', 29 | 64: 'BrightBlue', 30 | 65: 'BrightMagenta', 31 | 66: 'BrightCyan', 32 | 67: 'BrightWhite', 33 | } 34 | 35 | _256_colors = { 36 | 0: '#000000', 37 | 1: '#800000', 38 | 2: '#008000', 39 | 3: '#808000', 40 | 4: '#000080', 41 | 5: '#800080', 42 | 6: '#008080', 43 | 7: '#c0c0c0', 44 | 8: '#808080', 45 | 9: '#ff0000', 46 | 10: '#00ff00', 47 | 11: '#ffff00', 48 | 12: '#0000ff', 49 | 13: '#ff00ff', 50 | 14: '#00ffff', 51 | 15: '#ffffff', 52 | } 53 | _vals = (0, 95, 135, 175, 215, 255) 54 | _256_colors.update({ 55 | 16 + i: '#{:02x}{:02x}{:02x}'.format(*rgb) 56 | for i, rgb in enumerate(itertools.product(_vals, _vals, _vals)) 57 | }) 58 | _256_colors.update({ 59 | 232 + i: '#{0:02x}{0:02x}{0:02x}'.format(10 * i + 8) 60 | for i in range(24) 61 | }) 62 | 63 | 64 | def _token_from_lexer_state( 65 | bold: bool, 66 | faint: bool, 67 | fg_color: str | None, 68 | bg_color: str | None, 69 | ) -> pygments.token._TokenType: 70 | """Construct a token given the current lexer state. 71 | 72 | We can only emit one token even though we have a multiple-tuple state. 73 | To do work around this, we construct tokens like "Bold.Red". 74 | """ 75 | components: tuple[str, ...] = () 76 | 77 | if bold: 78 | components += ('Bold',) 79 | 80 | if faint: 81 | components += ('Faint',) 82 | 83 | if fg_color: 84 | components += (fg_color,) 85 | 86 | if bg_color: 87 | components += ('BG' + bg_color,) 88 | 89 | if len(components) == 0: 90 | return pygments.token.Text 91 | else: 92 | token = Color 93 | for component in components: 94 | token = getattr(token, component) 95 | return token 96 | 97 | 98 | DEFAULT_STYLE = { 99 | 'Black': '#000000', 100 | 'Red': '#ef2929', 101 | 'Green': '#8ae234', 102 | 'Yellow': '#fce94f', 103 | 'Blue': '#3465a4', 104 | 'Magenta': '#c509c5', 105 | 'Cyan': '#34e2e2', 106 | 'White': '#f5f5f5', 107 | 'BrightBlack': '#676767', 108 | 'BrightRed': '#ff6d67', 109 | 'BrightGreen': '#5ff967', 110 | 'BrightYellow': '#fefb67', 111 | 'BrightBlue': '#6871ff', 112 | 'BrightMagenta': '#ff76ff', 113 | 'BrightCyan': '#5ffdff', 114 | 'BrightWhite': '#feffff', 115 | } 116 | 117 | 118 | def color_tokens( 119 | fg_colors: dict[str, str] = DEFAULT_STYLE, 120 | bg_colors: dict[str, str] = DEFAULT_STYLE, 121 | enable_256color: bool = False, 122 | ) -> dict[pygments.token._TokenType, str]: 123 | """Return color tokens for a given set of colors. 124 | 125 | Pygments doesn't have a generic "color" token; instead everything is 126 | contextual (e.g. "comment" or "variable"). That doesn't make sense for us, 127 | where the colors actually *are* what we care about. 128 | 129 | This function will register combinations of tokens (things like "Red" or 130 | "Bold.Red.BGGreen") based on the colors passed in. 131 | 132 | You can also define the tokens yourself, but note that the token names are 133 | *not* currently guaranteed to be stable between releases as I'm not really 134 | happy with this approach. 135 | 136 | Optionally, you can enable 256-color support by passing 137 | `enable_256color=True`. This will (very slightly) increase the CSS size, 138 | but enable the use of 256-color in text. The reason this is optional and 139 | non-default is that it requires patching the Pygments formatter you're 140 | using, using the ExtendedColorHtmlFormatterMixin provided by this file. 141 | For more details on why and how, see the README. 142 | 143 | Usage: 144 | 145 | .. code-block:: python 146 | from pygments_ansi_color import color_tokens 147 | 148 | class MyStyle(pygments.styles.SomeStyle): 149 | styles = dict(pygments.styles.SomeStyle.styles) 150 | styles.update(color_tokens()) 151 | """ 152 | styles: dict[pygments.token._TokenType, str] = {} 153 | 154 | # Validates custom color IDs. 155 | if not set(fg_colors).issubset(DEFAULT_STYLE): # pragma: no cover (trivial) 156 | raise ValueError( 157 | f'Unrecognized {set(fg_colors).difference(DEFAULT_STYLE)}' 158 | ' foreground color', 159 | ) 160 | if not set(bg_colors).issubset(DEFAULT_STYLE): # pragma: no cover (trivial) 161 | raise ValueError( 162 | f'Unrecognized {set(bg_colors).difference(DEFAULT_STYLE)}' 163 | ' background color', 164 | ) 165 | 166 | # Merge the default colors with the user-provided colors. 167 | fg_colors = {**DEFAULT_STYLE, **fg_colors} 168 | bg_colors = {**DEFAULT_STYLE, **bg_colors} 169 | 170 | if enable_256color: 171 | styles[pygments.token.Token.C.Bold] = 'bold' 172 | styles[pygments.token.Token.C.Faint] = '' 173 | for i, color in _256_colors.items(): 174 | styles[getattr(pygments.token.Token.C, f'C{i}')] = color 175 | styles[getattr(pygments.token.Token.C, f'BGC{i}')] = f'bg:{color}' 176 | 177 | for color, color_value in fg_colors.items(): 178 | styles[getattr(C, color)] = color_value 179 | 180 | for color, color_value in bg_colors.items(): 181 | styles[getattr(C, f'BG{color}')] = f'bg:{color_value}' 182 | else: 183 | for bold, faint, fg_color, bg_color in itertools.product( 184 | (False, True), 185 | (False, True), 186 | {None} | set(fg_colors), 187 | {None} | set(bg_colors), 188 | ): 189 | token = _token_from_lexer_state(bold, faint, fg_color, bg_color) 190 | if token is not pygments.token.Text: 191 | value: list[str] = [] 192 | if bold: 193 | value.append('bold') 194 | if fg_color: 195 | value.append(fg_colors[fg_color]) 196 | if bg_color: 197 | value.append('bg:' + bg_colors[bg_color]) 198 | styles[token] = ' '.join(value) 199 | 200 | return styles 201 | 202 | 203 | class AnsiColorLexer(pygments.lexer.RegexLexer): 204 | name = 'ANSI Color' 205 | aliases = ('ansi-color', 'ansi', 'ansi-terminal') 206 | flags = re.DOTALL | re.MULTILINE 207 | 208 | bold: bool 209 | fant: bool 210 | fg_color: str | None 211 | bg_color: str | None 212 | 213 | def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: 214 | super().__init__(*args, **kwargs) 215 | self.reset_state() 216 | 217 | def reset_state(self) -> None: 218 | self.bold = False 219 | self.faint = False 220 | self.fg_color = None 221 | self.bg_color = None 222 | 223 | @property 224 | def current_token(self) -> pygments.token._TokenType: 225 | return _token_from_lexer_state( 226 | self.bold, self.faint, self.fg_color, self.bg_color, 227 | ) 228 | 229 | def process( 230 | self, 231 | match: re.Match[str], 232 | ) -> typing.Generator[ 233 | tuple[int, pygments.token._TokenType, str], 234 | ]: 235 | """Produce the next token and bit of text. 236 | 237 | Interprets the ANSI code (which may be a color code or some other 238 | code), changing the lexer state and producing a new token. If it's not 239 | a color code, we just strip it out and move on. 240 | 241 | Some useful reference for ANSI codes: 242 | * http://ascii-table.com/ansi-escape-sequences.php 243 | """ 244 | # "after_escape" contains everything after the start of the escape 245 | # sequence, up to the next escape sequence. We still need to separate 246 | # the content from the end of the escape sequence. 247 | after_escape = match.group(1) 248 | 249 | # TODO: this doesn't handle the case where the values are non-numeric. 250 | # This is rare but can happen for keyboard remapping, e.g. 251 | # '\x1b[0;59;"A"p' 252 | parsed = re.match( 253 | r'([0-9;=]*?)?([a-zA-Z])(.*)$', 254 | after_escape, 255 | re.DOTALL | re.MULTILINE, 256 | ) 257 | if parsed is None: 258 | # This shouldn't ever happen if we're given valid text + ANSI, but 259 | # people can provide us with utter junk, and we should tolerate it. 260 | text = after_escape 261 | else: 262 | value, code, text = parsed.groups() 263 | if code == 'm': # "m" is "Set Graphics Mode" 264 | # Special case \x1b[m is a reset code 265 | if value == '': 266 | self.reset_state() 267 | else: 268 | try: 269 | values = [int(v) for v in value.split(';')] 270 | except ValueError: 271 | # Shouldn't ever happen, but could with invalid ANSI. 272 | values = [] 273 | 274 | while len(values) > 0: 275 | value = values.pop(0) 276 | fg_color = _ansi_code_to_color.get(value - 30) 277 | bg_color = _ansi_code_to_color.get(value - 40) 278 | if fg_color: 279 | self.fg_color = fg_color 280 | elif bg_color: 281 | self.bg_color = bg_color 282 | elif value == 1: 283 | self.bold = True 284 | elif value == 2: 285 | self.faint = True 286 | elif value == 22: 287 | self.bold = False 288 | self.faint = False 289 | elif value == 39: 290 | self.fg_color = None 291 | elif value == 49: 292 | self.bg_color = None 293 | elif value == 0: 294 | self.reset_state() 295 | elif value in (38, 48): 296 | try: 297 | five = values.pop(0) 298 | color = values.pop(0) 299 | except IndexError: 300 | continue 301 | else: 302 | if five != 5: 303 | continue 304 | if 0 <= color <= 255: 305 | if value == 38: 306 | self.fg_color = f'C{color}' 307 | else: 308 | self.bg_color = f'C{color}' 309 | 310 | yield match.start(), self.current_token, text 311 | 312 | def ignore_unknown_escape(self, match: re.Match[str]) -> typing.Generator[ 313 | tuple[int, pygments.token._TokenType, str], 314 | ]: 315 | after = match.group(1) 316 | # mypy prints these out because it uses curses to determine colors 317 | # http://ascii-table.com/ansi-escape-sequences-vt-100.php 318 | if re.match(r'\([AB012]', after): 319 | yield match.start(), self.current_token, after[2:] 320 | else: 321 | yield match.start(), self.current_token, after 322 | 323 | tokens = { 324 | # states have to be native strings 325 | 'root': [ 326 | (r'\x1b\[([^\x1b]*)', process), 327 | (r'\x1b([^\x1b]*)', ignore_unknown_escape), 328 | (r'[^\x1b]+', pygments.token.Text), 329 | ], 330 | } 331 | 332 | 333 | class ExtendedColorHtmlFormatterMixin: 334 | 335 | def _get_css_classes(self, token: pygments.token._TokenType) -> str: 336 | classes = super()._get_css_classes(token) # type: ignore 337 | if token[0] == 'Color': 338 | classes += ' ' + ' '.join( 339 | self._get_css_class(getattr(C, part)) # type: ignore 340 | for part in token[1:] 341 | ) 342 | return classes 343 | -------------------------------------------------------------------------------- /pygments_ansi_color/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriskuehl/pygments-ansi-color/b05bb6fb664e6761107843176cb91750e5559d75/pygments_ansi_color/py.typed -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | mypy 3 | pytest 4 | types-pygments 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from setuptools import setup 4 | 5 | 6 | setup( 7 | name='pygments-ansi-color', 8 | version='0.3.0', 9 | classifiers=[ 10 | 'License :: OSI Approved :: Apache Software License', 11 | 'Programming Language :: Python :: 3', 12 | ], 13 | python_requires='>=3.9', 14 | install_requires=['pygments!=2.7.3'], 15 | packages=['pygments_ansi_color'], 16 | package_data={ 17 | 'pygments_ansi_color': ['py.typed'], 18 | }, 19 | entry_points={ 20 | 'pygments.lexers': [ 21 | 'ansi_color = pygments_ansi_color:AnsiColorLexer', 22 | ], 23 | }, 24 | ) 25 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriskuehl/pygments-ansi-color/b05bb6fb664e6761107843176cb91750e5559d75/tests/__init__.py -------------------------------------------------------------------------------- /tests/pygments_ansi_color_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | 5 | import pytest 6 | from pygments.formatters import HtmlFormatter 7 | from pygments.token import Text 8 | from pygments.token import Token 9 | 10 | import pygments_ansi_color as main 11 | from pygments_ansi_color import C 12 | from pygments_ansi_color import Color 13 | 14 | 15 | @pytest.mark.parametrize( 16 | ('bold', 'faint', 'fg_color', 'bg_color', 'expected'), 17 | ( 18 | (False, False, False, False, Text), 19 | (True, False, False, False, Color.Bold), 20 | (True, False, 'Red', False, Color.Bold.Red), 21 | (True, False, 'Red', 'Blue', Color.Bold.Red.BGBlue), 22 | (True, True, 'Red', 'Blue', Color.Bold.Faint.Red.BGBlue), 23 | ), 24 | ) 25 | def test_token_from_lexer_state(bold, faint, fg_color, bg_color, expected): 26 | ret = main._token_from_lexer_state(bold, faint, fg_color, bg_color) 27 | assert ret == expected 28 | 29 | 30 | @pytest.fixture 31 | def default_color_tokens(): 32 | return dict( 33 | itertools.chain.from_iterable( 34 | ( 35 | (getattr(C, name), value), 36 | (getattr(C, f'BG{name}'), f'bg:{value}'), 37 | ) 38 | for name, value in main.DEFAULT_STYLE.items() 39 | ), 40 | ) 41 | 42 | 43 | def test_color_tokens(default_color_tokens): 44 | fg_colors = {'Red': '#ff0000'} 45 | bg_colors = {'Green': '#00ff00'} 46 | ret = main.color_tokens(fg_colors, bg_colors) 47 | for key, value in { 48 | Color.BGGreen: 'bg:#00ff00', 49 | Color.Bold: 'bold', 50 | Color.Bold.BGGreen: 'bold bg:#00ff00', 51 | Color.Bold.Red: 'bold #ff0000', 52 | Color.Bold.Red.BGGreen: 'bold #ff0000 bg:#00ff00', 53 | Color.Bold.Faint: 'bold', 54 | Color.Bold.Faint.BGGreen: 'bold bg:#00ff00', 55 | Color.Bold.Faint.Red: 'bold #ff0000', 56 | Color.Bold.Faint.Red.BGGreen: 'bold #ff0000 bg:#00ff00', 57 | Color.Red: '#ff0000', 58 | Color.Red.BGGreen: '#ff0000 bg:#00ff00', 59 | Color.Faint: '', 60 | Color.Faint.BGGreen: 'bg:#00ff00', 61 | Color.Faint.Red: '#ff0000', 62 | Color.Faint.Red.BGGreen: '#ff0000 bg:#00ff00', 63 | }.items(): 64 | assert ret[key] == value 65 | 66 | 67 | def test_color_tokens_256color(default_color_tokens): 68 | fg_colors = {'Red': '#ff0000'} 69 | bg_colors = {'Green': '#00ff00'} 70 | expected = dict(default_color_tokens) 71 | expected.update( 72 | dict( 73 | itertools.chain.from_iterable( 74 | ( 75 | (getattr(C, f'C{i}'), value), 76 | (getattr(C, f'BGC{i}'), f'bg:{value}'), 77 | ) 78 | for i, value in main._256_colors.items() 79 | ), 80 | ), 81 | ) 82 | expected.update({ 83 | C.Red: '#ff0000', 84 | C.BGGreen: 'bg:#00ff00', 85 | C.Bold: 'bold', 86 | C.Faint: '', 87 | }) 88 | assert main.color_tokens( 89 | fg_colors, bg_colors, 90 | enable_256color=True, 91 | ) == expected 92 | 93 | 94 | def _highlight(text): 95 | return tuple(main.AnsiColorLexer().get_tokens(text)) 96 | 97 | 98 | def test_plain_text(): 99 | assert _highlight('hello world\n') == ( 100 | (Text, 'hello world\n'), 101 | ) 102 | 103 | 104 | def test_simple_colors(): 105 | assert _highlight( 106 | 'plain text\n' 107 | '\x1b[31mred text\n' 108 | '\x1b[1;32mbold green text\n' 109 | '\x1b[39mfg color turned off\n' 110 | '\x1b[0mplain text after reset\n' 111 | '\x1b[1mbold text\n' 112 | '\x1b[43mbold from previous line with yellow bg\n' 113 | '\x1b[49mbg color turned off\n' 114 | '\x1b[92mfg bright green\n' 115 | '\x1b[101mbg bright red\n' 116 | '\x1b[39;49mcolors turned off\n' 117 | '\x1b[2mfaint turned on\n' 118 | '\x1b[22mbold turned off\n', 119 | ) == ( 120 | (Text, 'plain text\n'), 121 | (Color.Red, 'red text\n'), 122 | (Color.Bold.Green, 'bold green text\n'), 123 | (Color.Bold, 'fg color turned off\n'), 124 | (Text, 'plain text after reset\n'), 125 | (Color.Bold, 'bold text\n'), 126 | (Color.Bold.BGYellow, 'bold from previous line with yellow bg\n'), 127 | (Color.Bold, 'bg color turned off\n'), 128 | (Color.Bold.BrightGreen, 'fg bright green\n'), 129 | (Color.Bold.BrightGreen.BGBrightRed, 'bg bright red\n'), 130 | (Color.Bold, 'colors turned off\n'), 131 | (Color.Bold.Faint, 'faint turned on\n'), 132 | (Text, 'bold turned off\n'), 133 | ) 134 | 135 | 136 | def test_256_colors(): 137 | assert _highlight( 138 | 'plain text\n' 139 | '\x1b[38;5;15mcolor 15\n' 140 | '\x1b[1mbold color 15\n' 141 | '\x1b[48;5;8mbold color 15 with color 8 bg\n' 142 | '\x1b[38;5;11;22mnot bold color 11 with color 8 bg\n' 143 | '\x1b[0mplain text after reset\n', 144 | ) == ( 145 | (Text, 'plain text\n'), 146 | (Color.C15, 'color 15\n'), 147 | (Color.Bold.C15, 'bold color 15\n'), 148 | (Color.Bold.C15.BGC8, 'bold color 15 with color 8 bg\n'), 149 | (Color.C11.BGC8, 'not bold color 11 with color 8 bg\n'), 150 | (Text, 'plain text after reset\n'), 151 | ) 152 | 153 | 154 | def test_256_colors_invalid_escapes(): 155 | assert _highlight( 156 | 'plain text\n' 157 | # second value is "4" instead of expected "5" 158 | '\x1b[38;4;15mA\n' 159 | # too few values 160 | '\x1b[38;15mB\n' 161 | # invalid values (not integers) 162 | '\x1b[38;4=;15mC\n' 163 | # invalid values (color larger than 255) 164 | '\x1b[38;5;937mD\n', 165 | ) == ( 166 | (Text, 'plain text\n'), 167 | (Text, 'A\n'), 168 | (Text, 'B\n'), 169 | (Text, 'C\n'), 170 | (Text, 'D\n'), 171 | ) 172 | 173 | 174 | def test_highlight_empty_end_specifier(): 175 | ret = _highlight('plain\x1b[31mred\x1b[mplain\n') 176 | assert ret == ((Text, 'plain'), (Color.Red, 'red'), (Text, 'plain\n')) 177 | 178 | 179 | @pytest.mark.parametrize( 180 | 's', 181 | ( 182 | pytest.param('\x1b[99m' 'plain text\n', id='unknown int code'), 183 | pytest.param('\x1b[=m' 'plain text\n', id='invalid non-int code'), 184 | pytest.param('\x1b(B' 'plain text\n', id='other unknown vt100 code'), 185 | pytest.param('\x1b' 'plain text\n', id='stray ESC'), 186 | ), 187 | ) 188 | def test_ignores_unrecognized_ansi_color_codes(s): 189 | """It should just strip and ignore any unrecognized color ANSI codes.""" 190 | assert _highlight(s) == ((Text, 'plain text\n'),) 191 | 192 | 193 | def test_ignores_valid_ansi_non_color_codes(): 194 | """It should just strip and ignore any non-color ANSI codes. 195 | 196 | These include things like moving the cursor position, erasing lines, etc. 197 | """ 198 | assert _highlight( 199 | # restore cursor position 200 | '\x1b[u' 'plain ' 201 | # move cursor backwards 55 steps 202 | '\x1b[55C' 'text\n', 203 | ) == ( 204 | # Ideally these would be just one token, but our regex isn't smart 205 | # enough yet. 206 | (Text, 'plain '), 207 | (Text, 'text\n'), 208 | ) 209 | 210 | 211 | def test_ignores_completely_invalid_escapes(): 212 | """It should strip and ignore invalid escapes. 213 | 214 | This shouldn't happen in valid ANSI text, but we could have an escape 215 | followed by garbage. 216 | """ 217 | assert _highlight( 218 | 'plain \x1b[%text\n', 219 | ) == ( 220 | (Text, 'plain '), 221 | (Text, '%text\n'), 222 | ) 223 | 224 | 225 | @pytest.fixture 226 | def test_formatter(): 227 | class TestFormatter(main.ExtendedColorHtmlFormatterMixin, HtmlFormatter): 228 | pass 229 | return TestFormatter() 230 | 231 | 232 | @pytest.mark.parametrize( 233 | ('token', 'expected_classes'), 234 | ( 235 | # Standard Pygments tokens shouldn't be changed. 236 | (Text, ''), 237 | (Token.Comment, 'c'), 238 | (Token.Comment.Multi, 'c c-Multi'), 239 | (Token.Operator, 'o'), 240 | 241 | # Non-standard (but non-Color) Pygments tokens also shouldn't be changed. 242 | (Token.Foo, ' -Foo'), 243 | (Token.Foo.Bar.Baz, ' -Foo -Foo-Bar -Foo-Bar-Baz'), 244 | 245 | # Color tokens should be split out into multiple, non-nested classes prefixed with "C". 246 | (Token.Color.Bold, ' -Color -Color-Bold -C-Bold'), 247 | (Token.Color.Red, ' -Color -Color-Red -C-Red'), 248 | (Token.Color.Bold.Red, ' -Color -Color-Bold -Color-Bold-Red -C-Bold -C-Red'), 249 | ( 250 | Token.Color.Bold.Red.BGGreen, 251 | ' -Color -Color-Bold -Color-Bold-Red -Color-Bold-Red-BGGreen -C-Bold -C-Red -C-BGGreen', 252 | ), 253 | (Token.Color.C5, ' -Color -Color-C5 -C-C5'), 254 | (Token.Color.C5.BGC18, ' -Color -Color-C5 -Color-C5-BGC18 -C-C5 -C-BGC18'), 255 | ), 256 | ) 257 | def test_formatter_mixin_get_css_classes(test_formatter, token, expected_classes): 258 | assert test_formatter._get_css_classes(token) == expected_classes 259 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py 3 | 4 | [testenv] 5 | deps = -rrequirements-dev.txt 6 | commands = 7 | coverage erase 8 | coverage run -m pytest {posargs:tests} 9 | coverage report --fail-under 100 10 | mypy pygments_ansi_color 11 | 12 | [flake8] 13 | max-line-length = 119 14 | 15 | [pep8] 16 | # autopep8 will rewrite lines to be shorter, even though we raised the length 17 | ignore = E501 18 | --------------------------------------------------------------------------------