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 # coma [options...] [regex/style pairs...]
  27 #
  28 # COlor MAtches colors/styles regex matches everywhere they're found,
  29 # using the named-style associated to the regex in its argument-pair.
  30 #
  31 # Lines not matching any regex stay verbatim.
  32 #
  33 # The colors/styles available are:
  34 #     blue
  35 #     bold
  36 #     gray
  37 #     green
  38 #     inverse
  39 #     magenta
  40 #     orange
  41 #     purple
  42 #     red
  43 #     underline
  44 #
  45 # Some style aliases are:
  46 #     b       blue
  47 #     g       green
  48 #     m       magenta
  49 #     o       orange
  50 #     p       purple
  51 #     r       red
  52 #     u       underline
  53 #     hi      inverse (highlight)
  54 
  55 
  56 from io import StringIO
  57 from re import compile, Pattern
  58 from sys import argv, exit, stderr, stdin, stdout
  59 from typing import List, Tuple
  60 
  61 
  62 # info is the message shown when the script isn't given any argument, or
  63 # when the leading argument is one of the standard cmd-line help options
  64 info = '''
  65 coma [options...] [regex/style pairs...]
  66 
  67 COlor MAtches colors/styles regex matches everywhere they're found,
  68 using the named-style associated to the regex in its argument-pair.
  69 
  70 Lines not matching any regex stay verbatim.
  71 
  72 The colors/styles available are:
  73     blue
  74     bold
  75     gray
  76     green
  77     inverse
  78     magenta
  79     orange
  80     purple
  81     red
  82     underline
  83 
  84 Some style aliases are:
  85     b       blue
  86     g       green
  87     m       magenta
  88     o       orange
  89     p       purple
  90     r       red
  91     u       underline
  92     hi      inverse (highlight)
  93 '''.strip()
  94 
  95 # a leading help-option arg means show the help message and quit
  96 if len(argv) == 2 and argv[1].lower() in ('-h', '--h', '-help', '--help'):
  97     print(info, file=stderr)
  98     exit(0)
  99 
 100 
 101 def fail(msg, code: int = 1) -> None:
 102     '''Show the error message given, and quit the app right away.'''
 103     print(f'\x1b[31m{msg}\x1b[0m', file=stderr)
 104     exit(code)
 105 
 106 
 107 def style_line(sb: StringIO, pairs: List[Tuple[Pattern, str]]) -> None:
 108     '''Does what it says, replacing content of the StringIO given to it.'''
 109 
 110     for (expr, style) in pairs:
 111         src = sb.getvalue()
 112         sb.truncate(0)
 113         sb.seek(0)
 114 
 115         # j keeps track of end of detected file-extensions, and is used
 116         # outside the regex-match loop to detect trailing parts in lines
 117         j = 0
 118 
 119         # matches is to keep track of whether any matches occurred
 120         matches = 0
 121 
 122         # replace all regex-matches on the line by surrounding each
 123         # matched substring with ANSI styles/resets
 124         for m in expr.finditer(src):
 125             matches += 1
 126             # remember previous index-end, used to emit the part before
 127             # the current match
 128             start = j
 129 
 130             i = m.start()
 131             j = m.end()
 132 
 133             # remember part before match
 134             write_slice(sb, src, start, i)
 135             # style current match
 136             sb.write(style)
 137             # copy match as is
 138             write_slice(sb, src, i, j)
 139             # reset style
 140             sb.write('\x1b[0m')
 141 
 142         if matches == 0:
 143             # avoid emptying lines with no matches
 144             sb.write(src)
 145 
 146         # no need to copy the line when it's not changing anyway
 147         if j > 0:
 148             # don't forget the last part of the line, or the whole line
 149             sb.write(src[j:])
 150 
 151 
 152 def write_slice(sb: StringIO, s: str, start: int, end: int) -> None:
 153     # '''Emit slice-like substrings without allocating slices.'''
 154     # for i in range(start, end):
 155     #     sb.write(s[i])
 156     sb.write(s[start:end])
 157 
 158 
 159 # names_aliases normalizes lookup keys for table names2styles
 160 names_aliases = {
 161     'b': 'blue',
 162     'g': 'green',
 163     'm': 'magenta',
 164     'o': 'orange',
 165     'p': 'purple',
 166     'r': 'red',
 167     'u': 'underline',
 168 
 169     'hi': 'inverse',
 170     'inv': 'inverse',
 171     'mag': 'magenta',
 172 
 173     'flip': 'inverse',
 174     'swap': 'inverse',
 175 
 176     'reset': 'plain',
 177     'highlight': 'inverse',
 178     'hilite': 'inverse',
 179     'invert': 'inverse',
 180     'inverted': 'inverse',
 181     'swapped': 'inverse',
 182 }
 183 
 184 # names2styles matches color/style names to their ANSI-style strings
 185 names2styles = {
 186     'blue': '\x1b[38;5;26m',
 187     'bold': '\x1b[1m',
 188     'gray': '\x1b[38;5;249m',
 189     'green': '\x1b[38;5;29m',
 190     'inverse': '\x1b[7m',
 191     'orange': '\x1b[38;5;166m',
 192     'magenta': '\x1b[38;5;165m',
 193     'plain': '\x1b[0m',
 194     'purple': '\x1b[38;5;99m',
 195     'red': '\x1b[31m',
 196     'underline': '\x1b[4m',
 197 }
 198 
 199 
 200 def restyle_lines(src) -> None:
 201     sb = StringIO()
 202     for line in src:
 203         # ignore trailing carriage-returns and line-feeds in lines
 204         end = len(line)
 205         if end > 1 and line[end - 2] == '\r' and line[end - 1] == '\n':
 206             end -= 2
 207         elif end > 0 and line[end - 1] == '\n':
 208             end -= 1
 209 
 210         sb.truncate(0)
 211         sb.seek(0)
 212         write_slice(sb, line, 0, end)
 213         style_line(sb, pairs)
 214         stdout.write(sb.getvalue())
 215         stdout.write('\n')
 216     sb.close()
 217 
 218 
 219 # no args or a leading help-option arg means show the help message and quit
 220 if len(argv) == 1 or argv[1] in ('-h', '--h', '-help', '--help'):
 221     print(info, file=stderr)
 222     exit(0)
 223 
 224 # ensure an even number of args, after any leading options
 225 if len(argv) % 2 != 1:
 226     pre = 'expected an even number of args as regex/style pairs'
 227     fail(f'{pre}, but got {len(argv) - 1} instead')
 228 
 229 # make regex/ANSI-style pairs which are directly usable
 230 pairs: List[Tuple[Pattern, str]] = []
 231 start_args = 1
 232 while start_args + 1 < len(argv):
 233     # compile regex
 234     try:
 235         expr = compile(argv[start_args + 0])
 236     except Exception as e:
 237         fail(e)
 238 
 239     # lookup style name
 240     name = argv[start_args + 1]
 241     if name in names_aliases:
 242         name = names_aliases[name]
 243     if not name in names2styles:
 244         fail(f'style named {name} not supported')
 245 
 246     # remember both for later
 247     pairs.append((expr, names2styles[name]))
 248     # previous check ensures args has an even number of items
 249     start_args += 2
 250 
 251 try:
 252     stdout.reconfigure(newline='\n', encoding='utf-8')
 253     restyle_lines(stdin)
 254 except BrokenPipeError:
 255     # quit quietly, instead of showing a confusing error message
 256     stderr.flush()
 257     stderr.close()
 258 except KeyboardInterrupt:
 259     # quit quietly, instead of showing a confusing error message
 260     stderr.flush()
 261     stderr.close()
 262     exit(2)
 263 except Exception as e:
 264     fail(e, 1)