File: ngron.py
   1 #!/usr/bin/python3
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright © 2020-2025 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 json import dumps, load
  27 from re import compile as compile_re
  28 from sys import argv, exit, stderr, stdin, stdout
  29 from typing import List, Union
  30 
  31 
  32 info = '''
  33 ngron [options...] [filepath/URI...]
  34 
  35 
  36 Nice GRON converts JSON data into `grep`-friendly lines, similar to what
  37 tool `gron` (GRep jsON; https://github.com/tomnomnom/gron) does.
  38 
  39 This tool uses `nicer` ANSI styles than the original, hence its name, but
  40 can't convert its output back into JSON, unlike the latter.
  41 
  42 Unlike the original `gron`, there's no sort-mode. When not given a named
  43 source (filepath/URI) to read from, data are read from standard input.
  44 
  45 Options, where leading double-dashes are also allowed:
  46 
  47     -h         show this help message
  48     -help      show this help message
  49 
  50     -m         monochrome (default), enables unstyled output-mode
  51     -c         color, enables ANSI-styled output-mode
  52     -color     enables ANSI-styled output-mode
  53 '''
  54 
  55 # handle standard help cmd-line options, quitting right away in that case
  56 if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'):
  57     print(info.strip())
  58     exit(0)
  59 
  60 
  61 # simple_str_re helps funcs restyle_path and emit_path figure out which
  62 # strings need quoting
  63 simple_str_re = compile_re('[a-zA-Z_][a-zA-Z_0-9]*')
  64 
  65 
  66 def gron_path(w, path: List[Union[int, str]]) -> None:
  67     w.write('json')
  68     for e in path:
  69         if isinstance(e, int):
  70             w.write(f'[{e}]')
  71             continue
  72         if simple_str_re.match(e):
  73             w.write(f'.{e}')
  74             continue
  75         w.write(f'[{dumps(e)}]')
  76     w.write(' = ')
  77 
  78 
  79 def restyle_path(w, path: List[Union[int, str]]) -> None:
  80     w.write('\x1b[38;2;135;95;255mjson')
  81     syntax = False
  82 
  83     for e in path:
  84         if isinstance(e, int):
  85             w.write('\x1b[38;2;168;168;168m[\x1b[38;2;0;135;95m')
  86             w.write(str(e))
  87             w.write('\x1b[38;2;168;168;168m]')
  88             syntax = True
  89             continue
  90 
  91         if simple_str_re.match(e):
  92             w.write(f'\x1b[38;2;168;168;168m.\x1b[38;2;135;95;255m{e}')
  93             syntax = False
  94             continue
  95 
  96         w.write('\x1b[38;2;168;168;168m["\x1b[38;2;0;95;135m')
  97         w.write(dumps(e)[1:-1])
  98         w.write('\x1b[38;2;168;168;168m"]')
  99         syntax = True
 100 
 101     if syntax:
 102         w.write(' = ')
 103     else:
 104         w.write('\x1b[38;2;168;168;168m = ')
 105 
 106 
 107 def end_styled_line(w) -> None:
 108     w.write('\x1b[38;2;168;168;168m;\x1b[0m\n')
 109 
 110 
 111 def colored_gron(w, data, path: List[Union[int, str]]) -> None:
 112     'This func is where all the recursive output-action starts/happens.'
 113 
 114     f = type2ansi[type(data)]
 115     if f != None:
 116         f(w, data, path)
 117     else:
 118         raise ValueError(f'unsupported type {type(data)}')
 119 
 120 
 121 def restyle_bool(w, data: bool, path: List[Union[int, str]]) -> None:
 122     'Handle booleans for func restyle.'
 123 
 124     restyle_path(w, path)
 125     if data:
 126         w.write('\x1b[38;2;95;175;215mtrue')
 127     else:
 128         w.write('\x1b[38;2;95;175;215mfalse')
 129     end_styled_line(w)
 130 
 131 
 132 def restyle_dict(w, data: dict, path: List[Union[int, str]]) -> None:
 133     'Handle dictionaries for func restyle.'
 134 
 135     restyle_path(w, path)
 136     w.write('\x1b[38;2;168;168;168m{};\x1b[0m\n')
 137 
 138     path.append('')
 139     for k, v in data.items():
 140         path[-1] = k
 141         colored_gron(w, v, path)
 142     del path[-1]
 143 
 144 
 145 def restyle_float(w, data: float, path: List[Union[int, str]]) -> None:
 146     'Handle floats for func restyle, emitting as many decimals as needed.'
 147 
 148     restyle_path(w, path)
 149     # n = int(data)
 150     # if float(n) != data:
 151     #     w.write(f'\x1b[38;2;0;135;95m{data:-1}\x1b[0m')
 152     # else:
 153     #     w.write(f'\x1b[38;2;0;135;95m{n}\x1b[0m')
 154     w.write(f'\x1b[38;2;0;135;95m{data:-1}\x1b[0m')
 155     end_styled_line(w)
 156 
 157 
 158 def restyle_int(w, data: int, path: List[Union[int, str]]) -> None:
 159     'Handle integers for func restyle.'
 160 
 161     restyle_path(w, path)
 162     w.write(f'\x1b[38;2;0;135;95m{data}\x1b[0m')
 163     end_styled_line(w)
 164 
 165 
 166 def restyle_list(w, data: list, path: List[Union[int, str]]) -> None:
 167     'Handle lists for func restyle.'
 168 
 169     restyle_path(w, path)
 170     w.write('\x1b[38;2;168;168;168m[];\x1b[0m\n')
 171 
 172     path.append(0)
 173     for i, v in enumerate(data):
 174         path[-1] = i
 175         colored_gron(w, v, path)
 176     del path[-1]
 177 
 178 
 179 def restyle_none(w, data: bool, path: List[Union[int, str]]) -> None:
 180     'Handle nulls for func restyle.'
 181 
 182     restyle_path(w, path)
 183     w.write('\x1b[38;2;168;168;168mnull')
 184     end_styled_line(w)
 185 
 186 
 187 def restyle_str(w, data: str, path: List[Union[int, str]]) -> None:
 188     'Handle strings for func restyle.'
 189 
 190     restyle_path(w, path)
 191     w.write(f'\x1b[38;2;168;168;168m"\x1b[0m')
 192     w.write(dumps(data)[1:-1])
 193     w.write(f'\x1b[38;2;168;168;168m"\x1b[0m')
 194     end_styled_line(w)
 195 
 196 
 197 def gron(w, data, path: List[Union[int, str]]) -> None:
 198     'Recursively output data into gron-format lines.'
 199 
 200     f = type2gron[type(data)]
 201     if f != None:
 202         f(w, data, path)
 203     else:
 204         raise ValueError(f'unsupported type {type(data)}')
 205 
 206 
 207 def gron_bool(w, data: bool, path: List[Union[int, str]]) -> None:
 208     'Handle booleans for func gron.'
 209 
 210     gron_path(w, path)
 211     if data:
 212         w.write('true;\n')
 213     else:
 214         w.write('false;\n')
 215 
 216 
 217 def gron_dict(w, data: dict, path: List[Union[int, str]]) -> None:
 218     'Handle dictionaries for func gron.'
 219 
 220     gron_path(w, path)
 221     w.write('{};\n')
 222 
 223     path.append('')
 224     for k, v in data.items():
 225         path[-1] = k
 226         gron(w, v, path)
 227     del path[-1]
 228 
 229 def gron_float(w, data: float, path: List[Union[int, str]]) -> None:
 230     'Handle floats for func gron, emitting as many decimals as needed.'
 231 
 232     gron_path(w, path)
 233     # n = int(data)
 234     # if float(n) != data:
 235     #     w.write(f'{data:-1};\n')
 236     # else:
 237     #     w.write(f'{n};\n')
 238     w.write(f'{data:-1};\n')
 239 
 240 
 241 def gron_int(w, data: int, path: List[Union[int, str]]) -> None:
 242     'Handle integers for func gron.'
 243 
 244     gron_path(w, path)
 245     w.write(f'{data};\n')
 246 
 247 
 248 def gron_list(w, data: list, path: List[Union[int, str]]) -> None:
 249     'Handle lists for func gron.'
 250 
 251     gron_path(w, path)
 252     w.write('[];\n')
 253 
 254     path.append(0)
 255     for i, v in enumerate(data):
 256         path[-1] = i
 257         gron(w, v, path)
 258     del path[-1]
 259 
 260 
 261 def gron_none(w, data: bool, path: List[Union[int, str]]) -> None:
 262     'Handle nulls for func gron.'
 263 
 264     gron_path(w, path)
 265     w.write('null;\n')
 266 
 267 
 268 def gron_str(w, data: str, path: List[Union[int, str]]) -> None:
 269     'Handle strings for func gron.'
 270 
 271     gron_path(w, path)
 272     w.write(f'{dumps(data)};\n')
 273 
 274 
 275 def seems_url(s: str) -> bool:
 276     protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:')
 277     return any(s.startswith(p) for p in protocols)
 278 
 279 
 280 # type2ansi helps func restyle dispatch styler funcs, using values' types
 281 # as the keys; while JSON doesn't have int values, the stdlib auto-promotes
 282 # round JSON numbers into ints, and so this table has a func for those too
 283 type2ansi = {
 284     type(None): restyle_none,
 285     bool: restyle_bool,
 286     int: restyle_int,
 287     float: restyle_float,
 288     str: restyle_str,
 289     list: restyle_list,
 290     tuple: restyle_list,
 291     dict: restyle_dict,
 292 }
 293 
 294 
 295 # type2gron helps func emit_gron dispatch emitter funcs, using values' types
 296 # as the keys; while JSON doesn't have int values, the stdlib auto-promotes
 297 # round JSON numbers into ints, and so this table has a func for those too
 298 type2gron = {
 299     type(None): gron_none,
 300     bool: gron_bool,
 301     int: gron_int,
 302     float: gron_float,
 303     str: gron_str,
 304     list: gron_list,
 305     tuple: gron_list,
 306     dict: gron_dict,
 307 }
 308 
 309 
 310 if len(argv) > 1 and argv[1].startswith('-'):
 311     l = argv[1].lstrip('-').lower()
 312     if l in ('c', 'color', 'nice'):
 313         emit = colored_gron
 314         args = argv[2:]
 315     elif l in ('g', 'gron', 'm', 'monochrome', 'p', 'plain'):
 316         emit = gron
 317         args = argv[2:]
 318     else:
 319         emit = gron
 320         args = argv[1:]
 321 else:
 322     args = argv[1:]
 323     emit = gron
 324 
 325 try:
 326     if len(args) > 1:
 327         raise ValueError('multiple inputs not allowed')
 328 
 329     name = args[0] if len(args) > 0 else '-'
 330 
 331     if name == '-':
 332         emit(stdout, load(stdin), [])
 333     elif seems_url(name):
 334         from urllib.request import urlopen
 335         with urlopen(name) as inp:
 336             emit(stdout, load(inp), [])
 337     else:
 338         with open(name, encoding='utf-8') as inp:
 339             emit(stdout, load(inp), [])
 340 except BrokenPipeError:
 341     # quit quietly, instead of showing a confusing error message
 342     stderr.close()
 343 except KeyboardInterrupt:
 344     exit(2)
 345 except Exception as e:
 346     print(f'\x1b[31m{e}\x1b[0m', file=stderr)
 347     exit(1)