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)