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