File: nn.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 # nn [options...] [filepaths/URIs...]
  27 #
  28 # Nice Numbers restyles all runs of 4+ digits by alternating ANSI-styles
  29 # every 3-digit group, so long numbers become easier to read at a glance.
  30 #
  31 # All (optional) leading options start with either single or double-dash,
  32 # and most of them change the style/color used. Some of the options are,
  33 # shown in their single-dash form:
  34 #
  35 #     -h          show this help message
  36 #     -help       show this help message
  37 #
  38 #     -b          use a blue color
  39 #     -blue       use a blue color
  40 #     -bold       bold-style digits
  41 #     -g          use a green color
  42 #     -gray       use a gray color (default)
  43 #     -green      use a green color
  44 #     -hi         use a highlighting/inverse style
  45 #     -m          use a magenta color
  46 #     -magenta    use a magenta color
  47 #     -o          use an orange color
  48 #     -orange     use an orange color
  49 #     -p          use a purple color
  50 #     -purple     use a purple color
  51 #     -r          use a red color
  52 #     -red        use a red color
  53 #     -u          underline digits
  54 #     -underline  underline digits
  55 
  56 
  57 # Note: string slicing is a major source of inefficiencies in this script,
  58 # making it viable only for small inputs; it's not clear what the stdlib
  59 # offers to loop over sub-strings without copying data, which is really
  60 # needed in this case.
  61 #
  62 # In the end the code has become much uglier by using explicit index-pairs,
  63 # which are used/updated all over to avoid copying sub-strings. Standard
  64 # output seems already line-buffered by default, which means explicit
  65 # output-buffering is unlikely to bring any noticeable speed-ups.
  66 
  67 
  68 from io import TextIOWrapper
  69 from sys import argv, exit, stderr, stdin, stdout
  70 from urllib.request import urlopen
  71 
  72 
  73 # info is the help message shown when asked to
  74 info = '''
  75 nn [options...] [filepaths/URIs...]
  76 
  77 Nice Numbers restyles all runs of 4+ digits by alternating ANSI-styles
  78 every 3-digit group, so long numbers become easier to read at a glance.
  79 
  80 All (optional) leading options start with either single or double-dash,
  81 and most of them change the style/color used. Some of the options are,
  82 shown in their single-dash form:
  83 
  84     -h          show this help message
  85     -help       show this help message
  86 
  87     -b          use a blue color
  88     -blue       use a blue color
  89     -bold       bold-style digits
  90     -g          use a green color
  91     -gray       use a gray color (default)
  92     -green      use a green color
  93     -hi         use a highlighting/inverse style
  94     -m          use a magenta color
  95     -magenta    use a magenta color
  96     -o          use an orange color
  97     -orange     use an orange color
  98     -p          use a purple color
  99     -purple     use a purple color
 100     -r          use a red color
 101     -red        use a red color
 102     -u          underline digits
 103     -underline  underline digits
 104 '''.strip()
 105 
 106 # handle standard help cmd-line options, quitting right away in that case
 107 if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'):
 108     print(info, file=stderr)
 109     exit(0)
 110 
 111 # names_aliases normalizes lookup keys for table names2styles
 112 names_aliases = {
 113     'b': 'blue',
 114     'g': 'green',
 115     'm': 'magenta',
 116     'o': 'orange',
 117     'p': 'purple',
 118     'r': 'red',
 119     'u': 'underline',
 120 
 121     'hi': 'inverse',
 122     'inv': 'inverse',
 123     'mag': 'magenta',
 124 
 125     'flip': 'inverse',
 126     'swap': 'inverse',
 127 
 128     'reset': 'plain',
 129     'highlight': 'inverse',
 130     'hilite': 'inverse',
 131     'invert': 'inverse',
 132     'inverted': 'inverse',
 133     'swapped': 'inverse',
 134 }
 135 
 136 # names2styles matches color/style names to their ANSI-style strings
 137 names2styles = {
 138     'blue': '\x1b[38;5;26m',
 139     'bold': '\x1b[1m',
 140     'gray': '\x1b[38;5;249m',
 141     'green': '\x1b[38;5;29m',
 142     'inverse': '\x1b[7m',
 143     'magenta': '\x1b[38;5;165m',
 144     'orange': '\x1b[38;5;166m',
 145     'plain': '\x1b[0m',
 146     'purple': '\x1b[38;5;99m',
 147     'red': '\x1b[31m',
 148     'underline': '\x1b[4m',
 149 }
 150 
 151 
 152 def restyle_line(w, line: str, style: str) -> None:
 153     '''Alternate styles for runs of digits in the string given'''
 154 
 155     start = 0
 156     end = len(line)
 157 
 158     if end > 1 and line[end - 2] == '\r' and line[end - 1] == '\n':
 159         end -= 2
 160     elif end > 0 and line[end - 1] == '\n':
 161         end -= 1
 162 
 163     while True:
 164         # see if line is over
 165         if start >= end:
 166             w.write('\n')
 167             return
 168 
 169         # find where the next run of digits starts; a negative index means
 170         # none were found
 171         i = -1
 172         for j in range(start, end):
 173             if line[j].isdigit():
 174                 i = j
 175                 break
 176 
 177         # check if rest of the line has no more digits
 178         if i < 0:
 179             w.write(line[start:end])
 180             w.write('\n')
 181             return
 182 
 183         # emit line up to right before the next run of digits starts
 184         w.write(line[start:i])
 185         start = i
 186 
 187         # find where/if the current run of digits ends; a negative index
 188         # means the run reaches the end of the line
 189         i = -1
 190         for j in range(start, end):
 191             if not line[j].isdigit():
 192                 i = j
 193                 break
 194 
 195         # check if rest of the line has only digits in it
 196         if i < 0:
 197             restyle_digits(w, line, start, end, style)
 198             w.write('\n')
 199             return
 200 
 201         # emit digits using alternate styling, and advance past them
 202         restyle_digits(w, line, start, i, style)
 203         start = i
 204 
 205 
 206 def restyle_digits(w, digits: str, start: int, end: int, style: str) -> None:
 207     '''Alternate styles on 3-item chunks from the string given'''
 208     diff = end - start
 209 
 210     # it's overall quicker to just emit short-enough digit-runs verbatim
 211     if diff < 4:
 212         w.write(digits[start:end])
 213         return
 214 
 215     # emit leading chunk of digits, which is the only one which
 216     # can have fewer than 3 items
 217     lead = diff % 3
 218     w.write(digits[start:start + lead])
 219 
 220     # the rest of the sub-string now has a multiple of 3 items left
 221     start += lead
 222 
 223     # start by styling the next digit-group only if there was a
 224     # non-empty leading group at the start of the full digit-run
 225     use_style = lead > 0
 226 
 227     # alternate styles until the string is over
 228     while start < end:
 229         # the digits left are always a multiple of 3
 230         stop = start + 3
 231 
 232         if use_style:
 233             w.write(style)
 234             w.write(digits[start:stop])
 235             w.write('\x1b[0m')
 236         else:
 237             w.write(digits[start:stop])
 238 
 239         # switch style and advance to the next 3-digit chunk
 240         use_style = not use_style
 241         start = stop
 242 
 243 
 244 def seems_url(s: str) -> bool:
 245     for prot in ('https://', 'http://', 'file://', 'ftp://', 'data:'):
 246         if s.startswith(prot):
 247             return True
 248     return False
 249 
 250 
 251 def handle_lines(w, src, style: str) -> None:
 252     for line in src:
 253         restyle_line(w, line, style)
 254 
 255 
 256 args = argv[1:]
 257 # default (alternate) style is a light-gray color
 258 style = names2styles['gray']
 259 
 260 # handle leading style/color option, if present
 261 if len(args) > 0 and args[0].startswith('-'):
 262     s = args[0].lstrip('-')
 263     if s in names_aliases:
 264         s = names_aliases[s]
 265     if s in names2styles:
 266         style = names2styles[s]
 267         # skip leading arg, since it's clearly not a filepath
 268         args = args[1:]
 269 
 270 try:
 271     if args.count('-') > 1:
 272         msg = 'reading from `-` (standard input) more than once not allowed'
 273         raise ValueError(msg)
 274 
 275     stdout.reconfigure(newline='\n', encoding='utf-8')
 276 
 277     # handle all named inputs given
 278     for path in args:
 279         if path == '-':
 280             handle_lines(stdout, stdin, style)
 281             continue
 282 
 283         if seems_url(path):
 284             with urlopen(path) as inp:
 285                 with TextIOWrapper(inp, encoding='utf-8') as txt:
 286                     handle_lines(stdout, txt, style)
 287             continue
 288 
 289         with open(path) as inp:
 290             handle_lines(stdout, inp, style)
 291 
 292     # when no filenames are given, handle lines from stdin
 293     if len(args) == 0:
 294         handle_lines(stdout, stdin, style)
 295 except (BrokenPipeError, KeyboardInterrupt):
 296     # quit quietly, instead of showing a confusing error message
 297     stderr.flush()
 298     stderr.close()
 299 except Exception as e:
 300     print(f'\x1b[31m{e}\x1b[0m', file=stderr)
 301     exit(1)