#!/usr/bin/python3 # The MIT License (MIT) # # Copyright © 2024 pacman64 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the “Software”), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. from re import compile, IGNORECASE, Match, Pattern from sys import argv, exit, stderr, stdin, stdout from typing import List, Tuple info = ''' icoma [regex/style pairs...] Insensitive COlor MAtches colors/styles case-insensitive regex-matches everywhere they're found, using the named-style associated to the regex in its argument-pair. Lines not matching any regex stay verbatim. The colors/styles available are: blue bold gray green inverse magenta orange purple red underline Some style aliases are: b blue g green m magenta o orange p purple r red u underline hi inverse (highlight) ''' # ansi_re matches ANSI-style sequences, so they're only matched `around` ansi_re = compile('\x1b\\[([0-9]*[A-HJKST]|[0-9;]*m)') def fail(msg, code: int = 1) -> None: 'Show the error message given, and quit the app right away.' print(f'\x1b[31m{msg}\x1b[0m', file=stderr) exit(code) def match(src: str, start: int, stop: int) -> Tuple[Match, str]: first = None style = '' for expr, st in pairs: m = expr.search(src, start, stop) if (not first) or (m and m.start() < first.start()): first = m style = st return first, style def style_line(w, s: str) -> None: # start is used outside the regex-match loop to handle trailing parts # in lines start = 0 # replace all regex-matches on the line by surrounding each matched # substring with ANSI styles/resets while True: m = ansi_re.search(s, start) if not m: start = style_chunk(w, s, start, len(s)) break stop = m.start() start = style_chunk(w, s, start, stop) # don't forget the last part of the line, or the whole line stop = m.end() w.write(s[start:stop]) start = stop # don't forget the last part of the line, or the whole line w.write(s[start:]) w.write('\n') def style_chunk(w, s: str, start: int, stop: int) -> int: while True: m, style = match(s, start, stop) if not m: return start i = m.start() j = m.end() # part before match w.write(s[start:i]) # current match w.write(style) w.write(s[i:j]) w.write('\x1b[0m') # the end of the match is the start of the `rest` of the string start = j # names_aliases normalizes lookup keys for table names2styles names_aliases = { 'b': 'blue', 'g': 'green', 'm': 'magenta', 'o': 'orange', 'p': 'purple', 'r': 'red', 'u': 'underline', 'bb': 'bblue', 'bg': 'bgreen', 'bm': 'bmagenta', 'bo': 'borange', 'bp': 'bpurple', 'br': 'bred', 'bu': 'bunderline', 'bb': 'bblue', 'gb': 'bgreen', 'mb': 'bmagenta', 'ob': 'borange', 'pb': 'bpurple', 'rb': 'bred', 'ub': 'bunderline', 'hi': 'inverse', 'inv': 'inverse', 'mag': 'magenta', 'flip': 'inverse', 'swap': 'inverse', 'reset': 'plain', 'highlight': 'inverse', 'hilite': 'inverse', 'invert': 'inverse', 'inverted': 'inverse', 'swapped': 'inverse', 'blueback': 'bblue', 'grayback': 'bgray', 'greenback': 'bgreen', 'magback': 'bmagenta', 'magentaback': 'bmagenta', 'orangeback': 'borange', 'purpleback': 'bpurple', 'redback': 'bred', 'bgblue': 'bblue', 'bggray': 'bgray', 'bggreen': 'bgreen', 'bgmag': 'bmagenta', 'bgmagenta': 'bmagenta', 'bgorange': 'borange', 'bgpurple': 'bpurple', 'bgred': 'bred', 'bluebg': 'bblue', 'graybg': 'bgray', 'greenbg': 'bgreen', 'magbg': 'bmagenta', 'magentabg': 'bmagenta', 'orangebg': 'borange', 'purplebg': 'bpurple', 'redbg': 'bred', 'backblue': 'bblue', 'backgray': 'bgray', 'backgreen': 'bgreen', 'backmag': 'bmagenta', 'backmagenta': 'bmagenta', 'backorange': 'borange', 'backpurple': 'bpurple', 'backred': 'bred', } # names2styles matches color/style names to their ANSI-style strings names2styles = { 'blue': '\x1b[38;5;26m', 'bold': '\x1b[1m', 'gray': '\x1b[38;5;248m', 'green': '\x1b[38;5;29m', 'inverse': '\x1b[7m', 'magenta': '\x1b[38;5;165m', 'orange': '\x1b[38;5;166m', 'plain': '\x1b[0m', 'purple': '\x1b[38;5;99m', 'red': '\x1b[31m', 'underline': '\x1b[4m', 'bblue': '\x1b[48;5;26m\x1b[38;5;15m', 'bgray': '\x1b[48;5;248m\x1b[38;5;15m', 'bgreen': '\x1b[48;5;29m\x1b[38;5;15m', 'bmagenta': '\x1b[48;5;165m\x1b[38;5;15m', 'borange': '\x1b[48;5;166m\x1b[38;5;15m', 'bpurple': '\x1b[48;5;99m\x1b[38;5;15m', 'bred': '\x1b[41m\x1b[38;5;15m', } # ensure an even number of args, after any leading options if len(argv) % 2 != 1: pre = 'expected an even number of args as regex/style pairs' fail(f'{pre}, but got {len(argv) - 1} instead') # make regex/ANSI-style pairs which are directly usable pairs: List[Tuple[Pattern, str]] = [] start_args = 1 while start_args + 1 < len(argv): # compile regex try: expr = compile(argv[start_args + 0], flags=IGNORECASE) except Exception as e: fail(e) # lookup style name name = argv[start_args + 1] if name in names_aliases: name = names_aliases[name] if not name in names2styles: fail(f'style named {name} not supported') # remember both for later pairs.append((expr, names2styles[name])) # previous check ensures args has an even number of items start_args += 2 try: for line in stdin: # ignore trailing carriage-returns and/or line-feeds in input lines line = line.rstrip('\r\n').rstrip('\n') style_line(stdout, line) except BrokenPipeError: # quit quietly, instead of showing a confusing error message pass except KeyboardInterrupt: exit(2) except Exception as e: fail(e, 1)