File: ngron.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright © 2026 pacman64
   5 
   6 Permission is hereby granted, free of charge, to any person obtaining a copy of
   7 this software and associated documentation files (the “Software”), to deal
   8 in the Software without restriction, including without limitation the rights to
   9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
  10 of the Software, and to permit persons to whom the Software is furnished to do
  11 so, subject to the following conditions:
  12 
  13 The above copyright notice and this permission notice shall be included in all
  14 copies or substantial portions of the Software.
  15 
  16 THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  22 SOFTWARE.
  23 */
  24 
  25 /*
  26 To compile a smaller-sized command-line app, you can use the `go` command as
  27 follows:
  28 
  29 go build -ldflags "-s -w" -trimpath ngron.go
  30 */
  31 
  32 package main
  33 
  34 import (
  35     "bufio"
  36     "encoding/json"
  37     "errors"
  38     "io"
  39     "os"
  40     "strconv"
  41 )
  42 
  43 const info = `
  44 ngron [options...] [filepath/URI...]
  45 
  46 
  47 Nice GRON converts JSON data into 'grep'-friendly lines, similar to what
  48 tool 'gron' (GRep jsON; https://github.com/tomnomnom/gron) does.
  49 
  50 This tool uses nicer ANSI styles than the original, hence its name, but
  51 can't convert its output back into JSON, unlike the latter.
  52 
  53 Unlike the original 'gron', there's no sort-mode. When not given a named
  54 source (filepath/URI) to read from, data are read from standard input.
  55 
  56 Options, where leading double-dashes are also allowed:
  57 
  58     -h         show this help message
  59     -help      show this help message
  60 
  61     -m         monochrome (default), enables unstyled output-mode
  62     -c         color, enables ANSI-styled output-mode
  63     -color     enables ANSI-styled output-mode
  64 `
  65 
  66 // errNoMoreOutput is a generic dummy output-error, which is meant to be
  67 // ultimately ignored, being just an excuse to quit the app immediately
  68 // and successfully
  69 var errNoMoreOutput = errors.New(`no more output`)
  70 
  71 type emitConfig struct {
  72     path    func(w *bufio.Writer, path []any) error
  73     null    func(w *bufio.Writer) error
  74     boolean func(w *bufio.Writer, b bool) error
  75     number  func(w *bufio.Writer, n json.Number) error
  76     key     func(w *bufio.Writer, k string) error
  77     text    func(w *bufio.Writer, s string) error
  78 
  79     arrayDecl  string
  80     objectDecl string
  81 }
  82 
  83 var monochrome = emitConfig{
  84     path:    monoPath,
  85     null:    monoNull,
  86     boolean: monoBool,
  87     number:  monoNumber,
  88     key:     monoString,
  89     text:    monoString,
  90 
  91     arrayDecl:  `[]`,
  92     objectDecl: `{}`,
  93 }
  94 
  95 var styled = emitConfig{
  96     path:    styledPath,
  97     null:    styledNull,
  98     boolean: styledBool,
  99     number:  styledNumber,
 100     key:     styledString,
 101     text:    styledString,
 102 
 103     arrayDecl:  "\x1b[38;2;168;168;168m[]\x1b[0m",
 104     objectDecl: "\x1b[38;2;168;168;168m{}\x1b[0m",
 105 }
 106 
 107 var config = monochrome
 108 
 109 func main() {
 110     args := os.Args[1:]
 111 
 112     if len(args) > 0 {
 113         switch args[0] {
 114         case `-h`, `--h`, `-help`, `--help`:
 115             os.Stdout.WriteString(info[1:])
 116             return
 117         case `-m`, `--m`:
 118             config = monochrome
 119             args = args[1:]
 120         case `-c`, `--c`, `-color`, `--color`:
 121             config = styled
 122             args = args[1:]
 123         }
 124     }
 125 
 126     if len(args) > 2 {
 127         os.Stderr.WriteString("multiple inputs not allowed\n")
 128         os.Exit(1)
 129     }
 130 
 131     // figure out whether input should come from a named file or from stdin
 132     path := `-`
 133     if len(args) > 0 {
 134         path = args[0]
 135     }
 136 
 137     err := handleInput(os.Stdout, path)
 138     if err != nil && err != io.EOF && err != errNoMoreOutput {
 139         os.Stderr.WriteString(err.Error())
 140         os.Stderr.WriteString("\n")
 141         os.Exit(1)
 142     }
 143 }
 144 
 145 type handlerFunc func(*bufio.Writer, *json.Decoder, json.Token, []any) error
 146 
 147 // handleInput simplifies control-flow for func main
 148 func handleInput(w io.Writer, path string) error {
 149     if path == `-` {
 150         bw := bufio.NewWriter(w)
 151         defer bw.Flush()
 152         return run(bw, os.Stdin)
 153     }
 154 
 155     f, err := os.Open(path)
 156     if err != nil {
 157         // on windows, file-not-found error messages may mention `CreateFile`,
 158         // even when trying to open files in read-only mode
 159         return errors.New(`can't open file named ` + path)
 160     }
 161     defer f.Close()
 162 
 163     bw := bufio.NewWriter(w)
 164     defer bw.Flush()
 165     return run(bw, f)
 166 }
 167 
 168 // escapedStringBytes helps func handleString treat all string bytes quickly
 169 // and correctly, using their officially-supported JSON escape sequences
 170 //
 171 // https://www.rfc-editor.org/rfc/rfc8259#section-7
 172 var escapedStringBytes = [256][]byte{
 173     {'\\', 'u', '0', '0', '0', '0'}, {'\\', 'u', '0', '0', '0', '1'},
 174     {'\\', 'u', '0', '0', '0', '2'}, {'\\', 'u', '0', '0', '0', '3'},
 175     {'\\', 'u', '0', '0', '0', '4'}, {'\\', 'u', '0', '0', '0', '5'},
 176     {'\\', 'u', '0', '0', '0', '6'}, {'\\', 'u', '0', '0', '0', '7'},
 177     {'\\', 'b'}, {'\\', 't'},
 178     {'\\', 'n'}, {'\\', 'u', '0', '0', '0', 'b'},
 179     {'\\', 'f'}, {'\\', 'r'},
 180     {'\\', 'u', '0', '0', '0', 'e'}, {'\\', 'u', '0', '0', '0', 'f'},
 181     {'\\', 'u', '0', '0', '1', '0'}, {'\\', 'u', '0', '0', '1', '1'},
 182     {'\\', 'u', '0', '0', '1', '2'}, {'\\', 'u', '0', '0', '1', '3'},
 183     {'\\', 'u', '0', '0', '1', '4'}, {'\\', 'u', '0', '0', '1', '5'},
 184     {'\\', 'u', '0', '0', '1', '6'}, {'\\', 'u', '0', '0', '1', '7'},
 185     {'\\', 'u', '0', '0', '1', '8'}, {'\\', 'u', '0', '0', '1', '9'},
 186     {'\\', 'u', '0', '0', '1', 'a'}, {'\\', 'u', '0', '0', '1', 'b'},
 187     {'\\', 'u', '0', '0', '1', 'c'}, {'\\', 'u', '0', '0', '1', 'd'},
 188     {'\\', 'u', '0', '0', '1', 'e'}, {'\\', 'u', '0', '0', '1', 'f'},
 189     {32}, {33}, {'\\', '"'}, {35}, {36}, {37}, {38}, {39},
 190     {40}, {41}, {42}, {43}, {44}, {45}, {46}, {47},
 191     {48}, {49}, {50}, {51}, {52}, {53}, {54}, {55},
 192     {56}, {57}, {58}, {59}, {60}, {61}, {62}, {63},
 193     {64}, {65}, {66}, {67}, {68}, {69}, {70}, {71},
 194     {72}, {73}, {74}, {75}, {76}, {77}, {78}, {79},
 195     {80}, {81}, {82}, {83}, {84}, {85}, {86}, {87},
 196     {88}, {89}, {90}, {91}, {'\\', '\\'}, {93}, {94}, {95},
 197     {96}, {97}, {98}, {99}, {100}, {101}, {102}, {103},
 198     {104}, {105}, {106}, {107}, {108}, {109}, {110}, {111},
 199     {112}, {113}, {114}, {115}, {116}, {117}, {118}, {119},
 200     {120}, {121}, {122}, {123}, {124}, {125}, {126}, {127},
 201     {128}, {129}, {130}, {131}, {132}, {133}, {134}, {135},
 202     {136}, {137}, {138}, {139}, {140}, {141}, {142}, {143},
 203     {144}, {145}, {146}, {147}, {148}, {149}, {150}, {151},
 204     {152}, {153}, {154}, {155}, {156}, {157}, {158}, {159},
 205     {160}, {161}, {162}, {163}, {164}, {165}, {166}, {167},
 206     {168}, {169}, {170}, {171}, {172}, {173}, {174}, {175},
 207     {176}, {177}, {178}, {179}, {180}, {181}, {182}, {183},
 208     {184}, {185}, {186}, {187}, {188}, {189}, {190}, {191},
 209     {192}, {193}, {194}, {195}, {196}, {197}, {198}, {199},
 210     {200}, {201}, {202}, {203}, {204}, {205}, {206}, {207},
 211     {208}, {209}, {210}, {211}, {212}, {213}, {214}, {215},
 212     {216}, {217}, {218}, {219}, {220}, {221}, {222}, {223},
 213     {224}, {225}, {226}, {227}, {228}, {229}, {230}, {231},
 214     {232}, {233}, {234}, {235}, {236}, {237}, {238}, {239},
 215     {240}, {241}, {242}, {243}, {244}, {245}, {246}, {247},
 216     {248}, {249}, {250}, {251}, {252}, {253}, {254}, {255},
 217 }
 218 
 219 // run does it all, given a reader and a writer
 220 func run(w *bufio.Writer, r io.Reader) error {
 221     dec := json.NewDecoder(r)
 222     // avoid parsing numbers, so unusually-long numbers are kept verbatim,
 223     // even if JSON parsers aren't required to guarantee such input-fidelity
 224     // for numbers
 225     dec.UseNumber()
 226 
 227     t, err := dec.Token()
 228     if err == io.EOF {
 229         return errors.New(`input has no JSON values`)
 230     }
 231 
 232     if err = handleToken(w, dec, t, make([]any, 0, 50)); err != nil {
 233         return err
 234     }
 235 
 236     _, err = dec.Token()
 237     if err == io.EOF {
 238         // input is over, so it's a success
 239         return nil
 240     }
 241 
 242     if err == nil {
 243         // a successful `read` is a failure, as it means there are
 244         // trailing JSON tokens
 245         return errors.New(`unexpected trailing data`)
 246     }
 247 
 248     // any other error, perhaps some invalid-JSON-syntax-type error
 249     return err
 250 }
 251 
 252 // handleToken handles recursion for func run
 253 func handleToken(w *bufio.Writer, dec *json.Decoder, t json.Token, path []any) error {
 254     switch t := t.(type) {
 255     case json.Delim:
 256         switch t {
 257         case json.Delim('['):
 258             return handleArray(w, dec, path)
 259         case json.Delim('{'):
 260             return handleObject(w, dec, path)
 261         default:
 262             return errors.New(`unsupported JSON syntax ` + string(t))
 263         }
 264 
 265     case nil:
 266         config.path(w, path)
 267         config.null(w)
 268         return endLine(w)
 269 
 270     case bool:
 271         config.path(w, path)
 272         config.boolean(w, t)
 273         return endLine(w)
 274 
 275     case json.Number:
 276         config.path(w, path)
 277         config.number(w, t)
 278         return endLine(w)
 279 
 280     case string:
 281         config.path(w, path)
 282         config.text(w, t)
 283         return endLine(w)
 284 
 285     default:
 286         // return fmt.Errorf(`unsupported token type %T`, t)
 287         return errors.New(`invalid JSON token`)
 288     }
 289 }
 290 
 291 // handleArray handles arrays for func handleToken
 292 func handleArray(w *bufio.Writer, dec *json.Decoder, path []any) error {
 293     config.path(w, path)
 294     w.WriteString(config.arrayDecl)
 295     if err := endLine(w); err != nil {
 296         return err
 297     }
 298 
 299     path = append(path, 0)
 300     last := len(path) - 1
 301 
 302     for i := 0; true; i++ {
 303         path[last] = i
 304 
 305         t, err := dec.Token()
 306         if err != nil {
 307             return err
 308         }
 309 
 310         if t == json.Delim(']') {
 311             return nil
 312         }
 313 
 314         err = handleToken(w, dec, t, path)
 315         if err != nil {
 316             return err
 317         }
 318     }
 319 
 320     // make the compiler happy
 321     return nil
 322 }
 323 
 324 // handleObject handles objects for func handleToken
 325 func handleObject(w *bufio.Writer, dec *json.Decoder, path []any) error {
 326     config.path(w, path)
 327     w.WriteString(config.objectDecl)
 328     if err := endLine(w); err != nil {
 329         return err
 330     }
 331 
 332     path = append(path, ``)
 333     last := len(path) - 1
 334 
 335     for i := 0; true; i++ {
 336         t, err := dec.Token()
 337         if err != nil {
 338             return err
 339         }
 340 
 341         if t == json.Delim('}') {
 342             return nil
 343         }
 344 
 345         k, ok := t.(string)
 346         if !ok {
 347             return errors.New(`expected a string for a key-value pair`)
 348         }
 349 
 350         path[last] = k
 351         if err != nil {
 352             return err
 353         }
 354 
 355         t, err = dec.Token()
 356         if err == io.EOF {
 357             return errors.New(`expected a value for a key-value pair`)
 358         }
 359 
 360         err = handleToken(w, dec, t, path)
 361         if err != nil {
 362             return err
 363         }
 364     }
 365 
 366     // make the compiler happy
 367     return nil
 368 }
 369 
 370 func monoPath(w *bufio.Writer, path []any) error {
 371     var buf [24]byte
 372 
 373     w.WriteString(`json`)
 374 
 375     for _, v := range path {
 376         switch v := v.(type) {
 377         case int:
 378             w.WriteByte('[')
 379             w.Write(strconv.AppendInt(buf[:0], int64(v), 10))
 380             w.WriteByte(']')
 381 
 382         case string:
 383             if !needsEscaping(v) {
 384                 w.WriteByte('.')
 385                 w.WriteString(v)
 386                 continue
 387             }
 388             w.WriteByte('[')
 389             monoString(w, v)
 390             w.WriteByte(']')
 391         }
 392     }
 393 
 394     w.WriteString(` = `)
 395     return nil
 396 }
 397 
 398 func monoNull(w *bufio.Writer) error {
 399     w.WriteString(`null`)
 400     return nil
 401 }
 402 
 403 func monoBool(w *bufio.Writer, b bool) error {
 404     if b {
 405         w.WriteString(`true`)
 406     } else {
 407         w.WriteString(`false`)
 408     }
 409     return nil
 410 }
 411 
 412 func monoNumber(w *bufio.Writer, n json.Number) error {
 413     w.WriteString(n.String())
 414     return nil
 415 }
 416 
 417 func monoString(w *bufio.Writer, s string) error {
 418     w.WriteByte('"')
 419     for i := range s {
 420         w.Write(escapedStringBytes[s[i]])
 421     }
 422     w.WriteByte('"')
 423     return nil
 424 }
 425 
 426 func styledPath(w *bufio.Writer, path []any) error {
 427     var buf [24]byte
 428 
 429     w.WriteString("\x1b[38;2;135;95;255mjson\x1b[0m")
 430 
 431     for _, v := range path {
 432         switch v := v.(type) {
 433         case int:
 434             w.WriteString("\x1b[38;2;168;168;168m[")
 435             w.WriteString("\x1b[38;2;0;135;95m")
 436             w.Write(strconv.AppendInt(buf[:0], int64(v), 10))
 437             w.WriteString("\x1b[38;2;168;168;168m]\x1b[0m")
 438 
 439         case string:
 440             if !needsEscaping(v) {
 441                 w.WriteString("\x1b[38;2;168;168;168m.")
 442                 w.WriteString("\x1b[38;2;135;95;255m")
 443                 w.WriteString(v)
 444                 w.WriteString("\x1b[0m")
 445                 continue
 446             }
 447 
 448             w.WriteString("\x1b[38;2;168;168;168m[")
 449             styledString(w, v)
 450             w.WriteString("\x1b[38;2;168;168;168m]\x1b[0m")
 451         }
 452     }
 453 
 454     w.WriteString(" \x1b[38;2;168;168;168m=\x1b[0m ")
 455     return nil
 456 }
 457 
 458 func styledNull(w *bufio.Writer) error {
 459     w.WriteString("\x1b[38;2;168;168;168m")
 460     w.WriteString(`null`)
 461     w.WriteString("\x1b[0m")
 462     return nil
 463 }
 464 
 465 func styledBool(w *bufio.Writer, b bool) error {
 466     if b {
 467         w.WriteString("\x1b[38;2;95;175;215mtrue\x1b[0m")
 468     } else {
 469         w.WriteString("\x1b[38;2;95;175;215mfalse\x1b[0m")
 470     }
 471     return nil
 472 }
 473 
 474 func styledNumber(w *bufio.Writer, n json.Number) error {
 475     w.WriteString("\x1b[38;2;0;135;95m")
 476     w.WriteString(n.String())
 477     w.WriteString("\x1b[0m")
 478     return nil
 479 }
 480 
 481 func styledString(w *bufio.Writer, s string) error {
 482     w.WriteString("\x1b[38;2;168;168;168m\"\x1b[0m")
 483     for i := range s {
 484         w.Write(escapedStringBytes[s[i]])
 485     }
 486     w.WriteString("\x1b[38;2;168;168;168m\"\x1b[0m")
 487     return nil
 488 }
 489 
 490 func needsEscaping(s string) bool {
 491     for _, r := range s {
 492         if r < ' ' || r > '~' {
 493             return true
 494         }
 495 
 496         switch r {
 497         case '\t', '\r', '\n', '\v', '"', '\\':
 498             return true
 499         }
 500     }
 501 
 502     return false
 503 }
 504 
 505 func endLine(w *bufio.Writer) error {
 506     w.WriteByte(';')
 507     if err := w.WriteByte('\n'); err != nil {
 508         return errNoMoreOutput
 509     }
 510     return nil
 511 }