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