File: ngron.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright © 2025 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.Stderr.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         const msg = "\x1b[31mmultiple inputs not allowed\x1b[0m\n"
 128         os.Stderr.WriteString(msg)
 129         os.Exit(1)
 130     }
 131 
 132     // figure out whether input should come from a named file or from stdin
 133     path := `-`
 134     if len(args) > 0 {
 135         path = args[0]
 136     }
 137 
 138     err := handleInput(os.Stdout, path)
 139     if err != nil && err != io.EOF && err != errNoMoreOutput {
 140         os.Stderr.WriteString("\x1b[31m")
 141         os.Stderr.WriteString(err.Error())
 142         os.Stderr.WriteString("\x1b[0m\n")
 143         os.Exit(1)
 144     }
 145 }
 146 
 147 type handlerFunc func(*bufio.Writer, *json.Decoder, json.Token, []any) error
 148 
 149 // handleInput simplifies control-flow for func main
 150 func handleInput(w io.Writer, path string) error {
 151     if path == `-` {
 152         bw := bufio.NewWriter(w)
 153         defer bw.Flush()
 154         return run(bw, os.Stdin)
 155     }
 156 
 157     f, err := os.Open(path)
 158     if err != nil {
 159         // on windows, file-not-found error messages may mention `CreateFile`,
 160         // even when trying to open files in read-only mode
 161         return errors.New(`can't open file named ` + path)
 162     }
 163     defer f.Close()
 164 
 165     bw := bufio.NewWriter(w)
 166     defer bw.Flush()
 167     return run(bw, f)
 168 }
 169 
 170 // escapedStringBytes helps func handleString treat all string bytes quickly
 171 // and correctly, using their officially-supported JSON escape sequences
 172 //
 173 // https://www.rfc-editor.org/rfc/rfc8259#section-7
 174 var escapedStringBytes = [256][]byte{
 175     {'\\', 'u', '0', '0', '0', '0'}, {'\\', 'u', '0', '0', '0', '1'},
 176     {'\\', 'u', '0', '0', '0', '2'}, {'\\', 'u', '0', '0', '0', '3'},
 177     {'\\', 'u', '0', '0', '0', '4'}, {'\\', 'u', '0', '0', '0', '5'},
 178     {'\\', 'u', '0', '0', '0', '6'}, {'\\', 'u', '0', '0', '0', '7'},
 179     {'\\', 'b'}, {'\\', 't'},
 180     {'\\', 'n'}, {'\\', 'u', '0', '0', '0', 'b'},
 181     {'\\', 'f'}, {'\\', 'r'},
 182     {'\\', 'u', '0', '0', '0', 'e'}, {'\\', 'u', '0', '0', '0', 'f'},
 183     {'\\', 'u', '0', '0', '1', '0'}, {'\\', 'u', '0', '0', '1', '1'},
 184     {'\\', 'u', '0', '0', '1', '2'}, {'\\', 'u', '0', '0', '1', '3'},
 185     {'\\', 'u', '0', '0', '1', '4'}, {'\\', 'u', '0', '0', '1', '5'},
 186     {'\\', 'u', '0', '0', '1', '6'}, {'\\', 'u', '0', '0', '1', '7'},
 187     {'\\', 'u', '0', '0', '1', '8'}, {'\\', 'u', '0', '0', '1', '9'},
 188     {'\\', 'u', '0', '0', '1', 'a'}, {'\\', 'u', '0', '0', '1', 'b'},
 189     {'\\', 'u', '0', '0', '1', 'c'}, {'\\', 'u', '0', '0', '1', 'd'},
 190     {'\\', 'u', '0', '0', '1', 'e'}, {'\\', 'u', '0', '0', '1', 'f'},
 191     {32}, {33}, {'\\', '"'}, {35}, {36}, {37}, {38}, {39},
 192     {40}, {41}, {42}, {43}, {44}, {45}, {46}, {47},
 193     {48}, {49}, {50}, {51}, {52}, {53}, {54}, {55},
 194     {56}, {57}, {58}, {59}, {60}, {61}, {62}, {63},
 195     {64}, {65}, {66}, {67}, {68}, {69}, {70}, {71},
 196     {72}, {73}, {74}, {75}, {76}, {77}, {78}, {79},
 197     {80}, {81}, {82}, {83}, {84}, {85}, {86}, {87},
 198     {88}, {89}, {90}, {91}, {'\\', '\\'}, {93}, {94}, {95},
 199     {96}, {97}, {98}, {99}, {100}, {101}, {102}, {103},
 200     {104}, {105}, {106}, {107}, {108}, {109}, {110}, {111},
 201     {112}, {113}, {114}, {115}, {116}, {117}, {118}, {119},
 202     {120}, {121}, {122}, {123}, {124}, {125}, {126}, {127},
 203     {128}, {129}, {130}, {131}, {132}, {133}, {134}, {135},
 204     {136}, {137}, {138}, {139}, {140}, {141}, {142}, {143},
 205     {144}, {145}, {146}, {147}, {148}, {149}, {150}, {151},
 206     {152}, {153}, {154}, {155}, {156}, {157}, {158}, {159},
 207     {160}, {161}, {162}, {163}, {164}, {165}, {166}, {167},
 208     {168}, {169}, {170}, {171}, {172}, {173}, {174}, {175},
 209     {176}, {177}, {178}, {179}, {180}, {181}, {182}, {183},
 210     {184}, {185}, {186}, {187}, {188}, {189}, {190}, {191},
 211     {192}, {193}, {194}, {195}, {196}, {197}, {198}, {199},
 212     {200}, {201}, {202}, {203}, {204}, {205}, {206}, {207},
 213     {208}, {209}, {210}, {211}, {212}, {213}, {214}, {215},
 214     {216}, {217}, {218}, {219}, {220}, {221}, {222}, {223},
 215     {224}, {225}, {226}, {227}, {228}, {229}, {230}, {231},
 216     {232}, {233}, {234}, {235}, {236}, {237}, {238}, {239},
 217     {240}, {241}, {242}, {243}, {244}, {245}, {246}, {247},
 218     {248}, {249}, {250}, {251}, {252}, {253}, {254}, {255},
 219 }
 220 
 221 // run does it all, given a reader and a writer
 222 func run(w *bufio.Writer, r io.Reader) error {
 223     dec := json.NewDecoder(r)
 224     // avoid parsing numbers, so unusually-long numbers are kept verbatim,
 225     // even if JSON parsers aren't required to guarantee such input-fidelity
 226     // for numbers
 227     dec.UseNumber()
 228 
 229     t, err := dec.Token()
 230     if err == io.EOF {
 231         return errors.New(`input has no JSON values`)
 232     }
 233 
 234     if err = handleToken(w, dec, t, make([]any, 0, 50)); err != nil {
 235         return err
 236     }
 237 
 238     _, err = dec.Token()
 239     if err == io.EOF {
 240         // input is over, so it's a success
 241         return nil
 242     }
 243 
 244     if err == nil {
 245         // a successful `read` is a failure, as it means there are
 246         // trailing JSON tokens
 247         return errors.New(`unexpected trailing data`)
 248     }
 249 
 250     // any other error, perhaps some invalid-JSON-syntax-type error
 251     return err
 252 }
 253 
 254 // handleToken handles recursion for func run
 255 func handleToken(w *bufio.Writer, dec *json.Decoder, t json.Token, path []any) error {
 256     switch t := t.(type) {
 257     case json.Delim:
 258         switch t {
 259         case json.Delim('['):
 260             return handleArray(w, dec, path)
 261         case json.Delim('{'):
 262             return handleObject(w, dec, path)
 263         default:
 264             return errors.New(`unsupported JSON syntax ` + string(t))
 265         }
 266 
 267     case nil:
 268         config.path(w, path)
 269         config.null(w)
 270         return endLine(w)
 271 
 272     case bool:
 273         config.path(w, path)
 274         config.boolean(w, t)
 275         return endLine(w)
 276 
 277     case json.Number:
 278         config.path(w, path)
 279         config.number(w, t)
 280         return endLine(w)
 281 
 282     case string:
 283         config.path(w, path)
 284         config.text(w, t)
 285         return endLine(w)
 286 
 287     default:
 288         // return fmt.Errorf(`unsupported token type %T`, t)
 289         return errors.New(`invalid JSON token`)
 290     }
 291 }
 292 
 293 // handleArray handles arrays for func handleToken
 294 func handleArray(w *bufio.Writer, dec *json.Decoder, path []any) error {
 295     config.path(w, path)
 296     w.WriteString(config.arrayDecl)
 297     if err := endLine(w); err != nil {
 298         return err
 299     }
 300 
 301     path = append(path, 0)
 302     last := len(path) - 1
 303 
 304     for i := 0; true; i++ {
 305         path[last] = i
 306 
 307         t, err := dec.Token()
 308         if err != nil {
 309             return err
 310         }
 311 
 312         if t == json.Delim(']') {
 313             return nil
 314         }
 315 
 316         err = handleToken(w, dec, t, path)
 317         if err != nil {
 318             return err
 319         }
 320     }
 321 
 322     // make the compiler happy
 323     return nil
 324 }
 325 
 326 // handleObject handles objects for func handleToken
 327 func handleObject(w *bufio.Writer, dec *json.Decoder, path []any) error {
 328     config.path(w, path)
 329     w.WriteString(config.objectDecl)
 330     if err := endLine(w); err != nil {
 331         return err
 332     }
 333 
 334     path = append(path, ``)
 335     last := len(path) - 1
 336 
 337     for i := 0; true; i++ {
 338         t, err := dec.Token()
 339         if err != nil {
 340             return err
 341         }
 342 
 343         if t == json.Delim('}') {
 344             return nil
 345         }
 346 
 347         k, ok := t.(string)
 348         if !ok {
 349             return errors.New(`expected a string for a key-value pair`)
 350         }
 351 
 352         path[last] = k
 353         if err != nil {
 354             return err
 355         }
 356 
 357         t, err = dec.Token()
 358         if err == io.EOF {
 359             return errors.New(`expected a value for a key-value pair`)
 360         }
 361 
 362         err = handleToken(w, dec, t, path)
 363         if err != nil {
 364             return err
 365         }
 366     }
 367 
 368     // make the compiler happy
 369     return nil
 370 }
 371 
 372 func monoPath(w *bufio.Writer, path []any) error {
 373     var buf [24]byte
 374 
 375     w.WriteString(`json`)
 376 
 377     for _, v := range path {
 378         switch v := v.(type) {
 379         case int:
 380             w.WriteByte('[')
 381             w.Write(strconv.AppendInt(buf[:0], int64(v), 10))
 382             w.WriteByte(']')
 383 
 384         case string:
 385             if !needsEscaping(v) {
 386                 w.WriteByte('.')
 387                 w.WriteString(v)
 388                 continue
 389             }
 390             w.WriteByte('[')
 391             monoString(w, v)
 392             w.WriteByte(']')
 393         }
 394     }
 395 
 396     w.WriteString(` = `)
 397     return nil
 398 }
 399 
 400 func monoNull(w *bufio.Writer) error {
 401     w.WriteString(`null`)
 402     return nil
 403 }
 404 
 405 func monoBool(w *bufio.Writer, b bool) error {
 406     if b {
 407         w.WriteString(`true`)
 408     } else {
 409         w.WriteString(`false`)
 410     }
 411     return nil
 412 }
 413 
 414 func monoNumber(w *bufio.Writer, n json.Number) error {
 415     w.WriteString(n.String())
 416     return nil
 417 }
 418 
 419 func monoString(w *bufio.Writer, s string) error {
 420     w.WriteByte('"')
 421     for i := range s {
 422         w.Write(escapedStringBytes[s[i]])
 423     }
 424     w.WriteByte('"')
 425     return nil
 426 }
 427 
 428 func styledPath(w *bufio.Writer, path []any) error {
 429     var buf [24]byte
 430 
 431     w.WriteString("\x1b[38;2;135;95;255mjson\x1b[0m")
 432 
 433     for _, v := range path {
 434         switch v := v.(type) {
 435         case int:
 436             w.WriteString("\x1b[38;2;168;168;168m[")
 437             w.WriteString("\x1b[38;2;0;135;95m")
 438             w.Write(strconv.AppendInt(buf[:0], int64(v), 10))
 439             w.WriteString("\x1b[38;2;168;168;168m]\x1b[0m")
 440 
 441         case string:
 442             if !needsEscaping(v) {
 443                 w.WriteString("\x1b[38;2;168;168;168m.")
 444                 w.WriteString("\x1b[38;2;135;95;255m")
 445                 w.WriteString(v)
 446                 w.WriteString("\x1b[0m")
 447                 continue
 448             }
 449 
 450             w.WriteString("\x1b[38;2;168;168;168m[")
 451             styledString(w, v)
 452             w.WriteString("\x1b[38;2;168;168;168m]\x1b[0m")
 453         }
 454     }
 455 
 456     w.WriteString(" \x1b[38;2;168;168;168m=\x1b[0m ")
 457     return nil
 458 }
 459 
 460 func styledNull(w *bufio.Writer) error {
 461     w.WriteString("\x1b[38;2;168;168;168m")
 462     w.WriteString(`null`)
 463     w.WriteString("\x1b[0m")
 464     return nil
 465 }
 466 
 467 func styledBool(w *bufio.Writer, b bool) error {
 468     if b {
 469         w.WriteString("\x1b[38;2;95;175;215mtrue\x1b[0m")
 470     } else {
 471         w.WriteString("\x1b[38;2;95;175;215mfalse\x1b[0m")
 472     }
 473     return nil
 474 }
 475 
 476 func styledNumber(w *bufio.Writer, n json.Number) error {
 477     w.WriteString("\x1b[38;2;0;135;95m")
 478     w.WriteString(n.String())
 479     w.WriteString("\x1b[0m")
 480     return nil
 481 }
 482 
 483 func styledString(w *bufio.Writer, s string) error {
 484     w.WriteString("\x1b[38;2;168;168;168m\"\x1b[0m")
 485     for i := range s {
 486         w.Write(escapedStringBytes[s[i]])
 487     }
 488     w.WriteString("\x1b[38;2;168;168;168m\"\x1b[0m")
 489     return nil
 490 }
 491 
 492 func needsEscaping(s string) bool {
 493     for _, r := range s {
 494         if r < ' ' || r > '~' {
 495             return true
 496         }
 497 
 498         switch r {
 499         case '\t', '\r', '\n', '\v', '"', '\\':
 500             return true
 501         }
 502     }
 503 
 504     return false
 505 }
 506 
 507 func endLine(w *bufio.Writer) error {
 508     w.WriteByte(';')
 509     if err := w.WriteByte('\n'); err != nil {
 510         return errNoMoreOutput
 511     }
 512     return nil
 513 }