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