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