File: coma.py 1 #!/usr/bin/python 2 3 # The MIT License (MIT) 4 # 5 # Copyright © 2026 pacman64 6 # 7 # Permission is hereby granted, free of charge, to any person obtaining a copy 8 # of this software and associated documentation files (the “Software”), to deal 9 # in the Software without restriction, including without limitation the rights 10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 # copies of the Software, and to permit persons to whom the Software is 12 # furnished to do so, subject to the following conditions: 13 # 14 # The above copyright notice and this permission notice shall be included in 15 # all copies or substantial portions of the Software. 16 # 17 # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 # SOFTWARE. 24 25 26 from io import SEEK_CUR 27 from itertools import islice 28 from re import compile, Match, Pattern, IGNORECASE 29 from sys import argv, exit, stderr, stdin, stdout 30 from typing import List, Tuple 31 32 33 info = ''' 34 coma [regex/style pairs...] 35 36 COlor MAtches colors/styles regex matches everywhere they're found, 37 using the named-style associated to the regex in its argument-pair. 38 39 Lines not matching any regex stay verbatim. 40 41 The colors/styles available are: 42 blue blueback 43 bold boldback 44 gray grayback 45 green greenback 46 inverse 47 magenta magentaback 48 orange orangeback 49 purple purpleback 50 red redback 51 underline 52 53 Some style aliases are: 54 b blue bb blueback 55 g green gb greenback 56 m magenta mb magentaback 57 o orange ob orangeback 58 p purple pb purpleback 59 r red rb redback 60 u underline 61 hi inverse (highlight) 62 ''' 63 64 # ansi_re matches ANSI-style sequences, so they're only matched `around` 65 ansi_re = compile('\x1b\\[[0-9;]*[A-Za-z]') 66 67 68 def fail(msg, code: int = 1) -> None: 69 'Show the error message given, and quit the app right away.' 70 print(f'\x1b[31m{msg}\x1b[0m', file=stderr) 71 exit(code) 72 73 74 def match(src: str, start: int, stop: int) -> Tuple[Match, str]: 75 first = None 76 style = '' 77 for expr, st in pairs: 78 m = expr.search(src, start, stop) 79 if not m or m.start() == m.end(): 80 continue 81 if not first or m.start() < first.start(): 82 first = m 83 style = st 84 return first, style 85 86 87 def style_line(w, s: str, live: bool) -> None: 88 # start is used outside the regex-match loop to handle trailing parts 89 # in lines 90 start = 0 91 92 # replace all regex-matches on the line by surrounding each matched 93 # substring with ANSI styles/resets 94 while True: 95 m = ansi_re.search(s, start) 96 if not m: 97 start = style_chunk(w, s, start, len(s)) 98 break 99 100 stop = m.start() 101 start = style_chunk(w, s, start, stop) 102 # don't forget the last part of the line, or the whole line 103 stop = m.end() 104 w.write(s[start:stop]) 105 start = stop 106 107 # don't forget the last part of the line, or the whole line 108 w.write(s[start:]) 109 w.write('\n') 110 111 if live: 112 w.flush() 113 114 115 def style_chunk(w, s: str, start: int, stop: int) -> int: 116 while True: 117 m, style = match(s, start, stop) 118 if not m: 119 return start 120 121 i = m.start() 122 j = m.end() 123 124 # part before match 125 w.write(s[start:i]) 126 127 # current match 128 w.write(style) 129 w.write(s[i:j]) 130 w.write('\x1b[0m') 131 132 # the end of the match is the start of the `rest` of the string 133 start = j 134 135 136 # names_aliases normalizes lookup keys for table names2styles 137 names_aliases = { 138 'b': 'blue', 139 'g': 'green', 140 'm': 'magenta', 141 'o': 'orange', 142 'p': 'purple', 143 'r': 'red', 144 'u': 'underline', 145 146 'bb': 'blueback', 147 'bg': 'greenback', 148 'bm': 'magentaback', 149 'bo': 'orangeback', 150 'bp': 'purpleback', 151 'br': 'redback', 152 153 'gb': 'greenback', 154 'mb': 'magentaback', 155 'ob': 'orangeback', 156 'pb': 'purpleback', 157 'rb': 'redback', 158 159 'hi': 'inverse', 160 'inv': 'inverse', 161 'mag': 'magenta', 162 163 'du': 'doubleunderline', 164 165 'flip': 'inverse', 166 'swap': 'inverse', 167 168 'reset': 'plain', 169 'highlight': 'inverse', 170 'hilite': 'inverse', 171 'invert': 'inverse', 172 'inverted': 'inverse', 173 'swapped': 'inverse', 174 175 'dunderline': 'doubleunderline', 176 'dunderlined': 'doubleunderline', 177 178 'strikethrough': 'strike', 179 'strikethru': 'strike', 180 'struck': 'strike', 181 182 'underlined': 'underline', 183 184 'bblue': 'blueback', 185 'bgray': 'grayback', 186 'bgreen': 'greenback', 187 'bmagenta': 'magentaback', 188 'borange': 'orangeback', 189 'bpurple': 'purpleback', 190 'bred': 'redback', 191 192 'bgblue': 'blueback', 193 'bggray': 'grayback', 194 'bggreen': 'greenback', 195 'bgmag': 'magentaback', 196 'bgmagenta': 'magentaback', 197 'bgorange': 'orangeback', 198 'bgpurple': 'purpleback', 199 'bgred': 'redback', 200 201 'bluebg': 'blueback', 202 'graybg': 'grayback', 203 'greenbg': 'greenback', 204 'magbg': 'magentaback', 205 'magentabg': 'magentaback', 206 'orangebg': 'orangeback', 207 'purplebg': 'purpleback', 208 'redbg': 'redback', 209 210 'backblue': 'blueback', 211 'backgray': 'grayback', 212 'backgreen': 'greenback', 213 'backmag': 'magentaback', 214 'backmagenta': 'magentaback', 215 'backorange': 'orangeback', 216 'backpurple': 'purpleback', 217 'backred': 'redback', 218 } 219 220 # names2styles matches color/style names to their ANSI-style strings 221 names2styles = { 222 'blue': '\x1b[38;2;0;95;215m', 223 'bold': '\x1b[1m', 224 'doubleunderline': '\x1b[21m', 225 'gray': '\x1b[38;2;168;168;168m', 226 'green': '\x1b[38;2;0;135;95m', 227 'inverse': '\x1b[7m', 228 'magenta': '\x1b[38;2;215;0;255m', 229 'orange': '\x1b[38;2;215;95;0m', 230 'plain': '\x1b[0m', 231 'purple': '\x1b[38;2;135;95;255m', 232 'red': '\x1b[38;2;204;0;0m', 233 'strike': '\x1b[9m', 234 'underline': '\x1b[4m', 235 236 'blueback': '\x1b[48;2;0;95;215m\x1b[38;2;238;238;238m', 237 'grayback': '\x1b[48;2;168;168;168m\x1b[38;2;238;238;238m', 238 'greenback': '\x1b[48;2;0;135;95m\x1b[38;2;238;238;238m', 239 'magentaback': '\x1b[48;2;215;0;255m\x1b[38;2;238;238;238m', 240 'orangeback': '\x1b[48;2;215;95;0m\x1b[38;2;238;238;238m', 241 'purpleback': '\x1b[48;2;135;95;255m\x1b[38;2;238;238;238m', 242 'redback': '\x1b[48;2;204;0;0m\x1b[38;2;238;238;238m', 243 } 244 245 246 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'): 247 print(info.strip('\n')) 248 exit(0) 249 250 flags = 0 251 args = argv[1:] 252 253 if len(args) > 0 and args[0] in ('-i', '--i', '-ins', '--ins'): 254 flags = IGNORECASE 255 args = args[1:] 256 257 if len(args) > 0 and args[0] == '--': 258 args = args[1:] 259 260 # ensure an even number of args, after any leading options 261 if len(args) % 2 != 0: 262 msg = 'expected an even number of args as regex/style pairs' 263 fail(f'{msg}, but got {len(args)} instead') 264 265 # make regex/ANSI-style pairs which are directly usable 266 pairs: List[Tuple[Pattern, str]] = [] 267 for src, name in zip(islice(args, 0, None, 2), islice(args, 1, None, 2)): 268 try: 269 expr = compile(src, flags=flags) 270 except Exception as e: 271 fail(e) 272 273 if name in names_aliases: 274 name = names_aliases[name] 275 if not name in names2styles: 276 fail(f'style named {name} not supported') 277 278 pairs.append((expr, names2styles[name])) 279 280 try: 281 stdout.seek(0, SEEK_CUR) 282 live = False 283 except: 284 live = True 285 286 try: 287 for line in stdin: 288 line = line.rstrip('\r\n').rstrip('\n') 289 style_line(stdout, line, live) 290 except BrokenPipeError: 291 # quit quietly, instead of showing a confusing error message 292 pass 293 except KeyboardInterrupt: 294 exit(2) 295 except Exception as e: 296 fail(e, 1)