File: coma.py
   1 #!/usr/bin/python3
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright © 2024 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 re import compile, Match, Pattern
  27 from sys import argv, exit, stderr, stdin, stdout
  28 from typing import List, Tuple
  29 
  30 
  31 info = '''
  32 coma [regex/style pairs...]
  33 
  34 COlor MAtches colors/styles regex matches everywhere they're found,
  35 using the named-style associated to the regex in its argument-pair.
  36 
  37 Lines not matching any regex stay verbatim.
  38 
  39 The colors/styles available are:
  40     blue
  41     bold
  42     gray
  43     green
  44     inverse
  45     magenta
  46     orange
  47     purple
  48     red
  49     underline
  50 
  51 Some style aliases are:
  52     b       blue
  53     g       green
  54     m       magenta
  55     o       orange
  56     p       purple
  57     r       red
  58     u       underline
  59     hi      inverse (highlight)
  60 '''
  61 
  62 # ansi_re matches ANSI-style sequences, so they're only matched `around`
  63 ansi_re = compile('\x1b\\[([0-9]*[A-HJKST]|[0-9;]*m)')
  64 
  65 
  66 def fail(msg, code: int = 1) -> None:
  67     'Show the error message given, and quit the app right away.'
  68     print(f'\x1b[31m{msg}\x1b[0m', file=stderr)
  69     exit(code)
  70 
  71 
  72 def match(src: str, start: int, stop: int) -> Tuple[Match, str]:
  73     first = None
  74     style = ''
  75     for expr, st in pairs:
  76         m = expr.search(src, start, stop)
  77         if (not first) or (m and m.start() < first.start()):
  78             first = m
  79             style = st
  80     return first, style
  81 
  82 
  83 def style_line(w, s: str) -> None:
  84     # start is used outside the regex-match loop to handle trailing parts
  85     # in lines
  86     start = 0
  87 
  88     # replace all regex-matches on the line by surrounding each matched
  89     # substring with ANSI styles/resets
  90     while True:
  91         m = ansi_re.search(s, start)
  92         if not m:
  93             start = style_chunk(w, s, start, len(s))
  94             break
  95 
  96         stop = m.start()
  97         start = style_chunk(w, s, start, stop)
  98         # don't forget the last part of the line, or the whole line
  99         stop = m.end()
 100         w.write(s[start:stop])
 101         start = stop
 102 
 103     # don't forget the last part of the line, or the whole line
 104     w.write(s[start:])
 105     w.write('\n')
 106 
 107 
 108 def style_chunk(w, s: str, start: int, stop: int) -> int:
 109     while True:
 110         m, style = match(s, start, stop)
 111         if not m:
 112             return start
 113 
 114         i = m.start()
 115         j = m.end()
 116 
 117         # part before match
 118         w.write(s[start:i])
 119 
 120         # current match
 121         w.write(style)
 122         w.write(s[i:j])
 123         w.write('\x1b[0m')
 124 
 125         # the end of the match is the start of the `rest` of the string
 126         start = j
 127 
 128 
 129 # names_aliases normalizes lookup keys for table names2styles
 130 names_aliases = {
 131     'b': 'blue',
 132     'g': 'green',
 133     'm': 'magenta',
 134     'o': 'orange',
 135     'p': 'purple',
 136     'r': 'red',
 137     'u': 'underline',
 138 
 139     'bb': 'bblue',
 140     'bg': 'bgreen',
 141     'bm': 'bmagenta',
 142     'bo': 'borange',
 143     'bp': 'bpurple',
 144     'br': 'bred',
 145     'bu': 'bunderline',
 146 
 147     'bb': 'bblue',
 148     'gb': 'bgreen',
 149     'mb': 'bmagenta',
 150     'ob': 'borange',
 151     'pb': 'bpurple',
 152     'rb': 'bred',
 153     'ub': 'bunderline',
 154 
 155     'hi': 'inverse',
 156     'inv': 'inverse',
 157     'mag': 'magenta',
 158 
 159     'flip': 'inverse',
 160     'swap': 'inverse',
 161 
 162     'reset': 'plain',
 163     'highlight': 'inverse',
 164     'hilite': 'inverse',
 165     'invert': 'inverse',
 166     'inverted': 'inverse',
 167     'swapped': 'inverse',
 168 
 169     'blueback': 'bblue',
 170     'grayback': 'bgray',
 171     'greenback': 'bgreen',
 172     'magback': 'bmagenta',
 173     'magentaback': 'bmagenta',
 174     'orangeback': 'borange',
 175     'purpleback': 'bpurple',
 176     'redback': 'bred',
 177 
 178     'bgblue': 'bblue',
 179     'bggray': 'bgray',
 180     'bggreen': 'bgreen',
 181     'bgmag': 'bmagenta',
 182     'bgmagenta': 'bmagenta',
 183     'bgorange': 'borange',
 184     'bgpurple': 'bpurple',
 185     'bgred': 'bred',
 186 
 187     'bluebg': 'bblue',
 188     'graybg': 'bgray',
 189     'greenbg': 'bgreen',
 190     'magbg': 'bmagenta',
 191     'magentabg': 'bmagenta',
 192     'orangebg': 'borange',
 193     'purplebg': 'bpurple',
 194     'redbg': 'bred',
 195 
 196     'backblue': 'bblue',
 197     'backgray': 'bgray',
 198     'backgreen': 'bgreen',
 199     'backmag': 'bmagenta',
 200     'backmagenta': 'bmagenta',
 201     'backorange': 'borange',
 202     'backpurple': 'bpurple',
 203     'backred': 'bred',
 204 }
 205 
 206 # names2styles matches color/style names to their ANSI-style strings
 207 names2styles = {
 208     'blue': '\x1b[38;5;26m',
 209     'bold': '\x1b[1m',
 210     'gray': '\x1b[38;5;248m',
 211     'green': '\x1b[38;5;29m',
 212     'inverse': '\x1b[7m',
 213     'magenta': '\x1b[38;5;165m',
 214     'orange': '\x1b[38;5;166m',
 215     'plain': '\x1b[0m',
 216     'purple': '\x1b[38;5;99m',
 217     'red': '\x1b[31m',
 218     'underline': '\x1b[4m',
 219 
 220     'bblue': '\x1b[48;5;26m\x1b[38;5;15m',
 221     'bgray': '\x1b[48;5;248m\x1b[38;5;15m',
 222     'bgreen': '\x1b[48;5;29m\x1b[38;5;15m',
 223     'bmagenta': '\x1b[48;5;165m\x1b[38;5;15m',
 224     'borange': '\x1b[48;5;166m\x1b[38;5;15m',
 225     'bpurple': '\x1b[48;5;99m\x1b[38;5;15m',
 226     'bred': '\x1b[41m\x1b[38;5;15m',
 227 }
 228 
 229 
 230 # ensure an even number of args, after any leading options
 231 if len(argv) % 2 != 1:
 232     pre = 'expected an even number of args as regex/style pairs'
 233     fail(f'{pre}, but got {len(argv) - 1} instead')
 234 
 235 # make regex/ANSI-style pairs which are directly usable
 236 pairs: List[Tuple[Pattern, str]] = []
 237 start_args = 1
 238 while start_args + 1 < len(argv):
 239     # compile regex
 240     try:
 241         expr = compile(argv[start_args + 0])
 242     except Exception as e:
 243         fail(e)
 244 
 245     # lookup style name
 246     name = argv[start_args + 1]
 247     if name in names_aliases:
 248         name = names_aliases[name]
 249     if not name in names2styles:
 250         fail(f'style named {name} not supported')
 251 
 252     # remember both for later
 253     pairs.append((expr, names2styles[name]))
 254     # previous check ensures args has an even number of items
 255     start_args += 2
 256 
 257 try:
 258     for line in stdin:
 259         # ignore trailing carriage-returns and/or line-feeds in input lines
 260         line = line.rstrip('\r\n').rstrip('\n')
 261         style_line(stdout, line)
 262 except BrokenPipeError:
 263     # quit quietly, instead of showing a confusing error message
 264     pass
 265 except KeyboardInterrupt:
 266     exit(2)
 267 except Exception as e:
 268     fail(e, 1)