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