File: ecoli.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 # ecoli [options...] [regex/style pairs...]
  27 #
  28 # Expressions COloring LInes tries to match each line to the regexes given,
  29 # coloring/styling with the named-style associated to the first match, if
  30 # any.
  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 #     red
  43 #     underline
  44 #
  45 # Some style aliases are:
  46 #     b       blue
  47 #     g       green
  48 #     m       magenta
  49 #     o       orange
  50 #     r       red
  51 #     u       underline
  52 #     hi      inverse (highlight)
  53 
  54 
  55 from re import compile, Pattern
  56 from sys import argv, exit, stderr, stdin, stdout
  57 from typing import List, Tuple
  58 
  59 
  60 # info is the message shown when the script isn't given any argument, or
  61 # when the leading argument is one of the standard cmd-line help options
  62 info = '''
  63 ecoli [options...] [regex/style pairs...]
  64 
  65 Expressions COloring LInes tries to match each line to the regexes given,
  66 coloring/styling with the named-style associated to the first match, if
  67 any.
  68 
  69 Lines not matching any regex stay verbatim.
  70 
  71 The colors/styles available are:
  72     blue
  73     bold
  74     gray
  75     green
  76     inverse
  77     magenta
  78     orange
  79     red
  80     underline
  81 
  82 Some style aliases are:
  83     b       blue
  84     g       green
  85     m       magenta
  86     o       orange
  87     r       red
  88     u       underline
  89     hi      inverse (highlight)
  90 '''.strip()
  91 
  92 
  93 def fail(msg, code: int = 1) -> None:
  94     '''Show the error message given, and quit the app right away.'''
  95     print(f'\x1b[31m{msg}\x1b[0m', file=stderr)
  96     exit(code)
  97 
  98 
  99 def style_line(w, line: str, pairs: List[Tuple[Patternstr]]) -> None:
 100     '''Does what it says, emitting output to stdout.'''
 101 
 102     for (expr, style) in pairs:
 103         # regexes can match anywhere on the line
 104         if expr.search(line) != None:
 105             w.write(style)
 106             w.write(line)
 107             w.write('\x1b[0m\n')
 108             return
 109 
 110     # emit the line as is, when no regex matches it
 111     w.write(line)
 112     w.write('\n')
 113 
 114 
 115 # names_aliases normalizes lookup keys for table names2styles
 116 names_aliases = {
 117     'b': 'blue',
 118     'g': 'green',
 119     'm': 'magenta',
 120     'o': 'orange',
 121     'r': 'red',
 122     'u': 'underline',
 123 
 124     'hi': 'inverse',
 125     'inv': 'inverse',
 126     'mag': 'magenta',
 127 
 128     'flip': 'inverse',
 129     'swap': 'inverse',
 130 
 131     'reset': 'plain',
 132     'highlight': 'inverse',
 133     'hilite': 'inverse',
 134     'invert': 'inverse',
 135     'inverted': 'inverse',
 136     'swapped': 'inverse',
 137 }
 138 
 139 # names2styles matches color/style names to their ANSI-style strings
 140 names2styles = {
 141     'blue': '\x1b[38;5;26m',
 142     'bold': '\x1b[1m',
 143     'gray': '\x1b[38;5;249m',
 144     'green': '\x1b[38;5;29m',
 145     'inverse': '\x1b[7m',
 146     'magenta': '\x1b[38;5;99m',
 147     'orange': '\x1b[38;5;166m',
 148     'plain': '\x1b[0m',
 149     'red': '\x1b[31m',
 150     'underline': '\x1b[4m',
 151 }
 152 
 153 
 154 # no args or a leading help-option arg means show the help message and quit
 155 if len(argv) == 1 or argv[1] in ('-h', '--h', '-help', '--help'):
 156     print(info, file=stderr)
 157     exit(0)
 158 
 159 # ensure an even number of args, after any leading options
 160 if len(argv) % 2 != 1:
 161     pre = 'expected an even number of args as regex/style pairs'
 162     fail(f'{pre}, but got {len(argv) - 1} instead')
 163 
 164 # make regex/ANSI-style pairs which are directly usable
 165 pairs: List[Tuple[Patternstr]] = []
 166 start_args = 1
 167 while start_args + 1 < len(argv):
 168     # compile regex
 169     try:
 170         expr = compile(argv[start_args + 0])
 171     except Exception as e:
 172         fail(e)
 173 
 174     # lookup style name
 175     name = argv[start_args + 1]
 176     if name in names_aliases:
 177         name = names_aliases[name]
 178     if not name in names2styles:
 179         fail(f'style named {name} not supported')
 180 
 181     # remember both for later
 182     pairs.append((expr, names2styles[name]))
 183     # previous check ensures args has an even number of items
 184     start_args += 2
 185 
 186 try:
 187     stdout.reconfigure(newline='\n', encoding='utf-8')
 188     for line in stdin:
 189         # ignore trailing carriage-returns and/or line-feeds in input lines
 190         style_line(stdout, line.rstrip('\r\n').rstrip('\n'), pairs)
 191 except BrokenPipeError:
 192     # quit quietly, instead of showing a confusing error message
 193     stderr.flush()
 194     stderr.close()
 195 except KeyboardInterrupt:
 196     # quit quietly, instead of showing a confusing error message
 197     stderr.flush()
 198     stderr.close()
 199     exit(2)