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