File: nj.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright © 2020-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 Single-file source-code for nj: this version has no http(s) support. Even
  27 the unit-tests from the original nj are omitted.
  28 
  29 To compile a smaller-sized command-line app, you can use the `go` command as
  30 follows:
  31 
  32 go build -ldflags "-s -w" -trimpath nj.go
  33 */
  34 
  35 package main
  36 
  37 import (
  38     "bufio"
  39     "encoding/json"
  40     "errors"
  41     "io"
  42     "os"
  43 )
  44 
  45 const info = `
  46 nj [filepath...]
  47 
  48 Nice Json reads JSON, and emits it back as ANSI-styled indented lines, using
  49 2 spaces for each indentation level.
  50 `
  51 
  52 // indent is how many spaces each indentation level uses
  53 const indent = 2
  54 
  55 const (
  56     // boolStyle is bluish, and very distinct from all other colors used
  57     boolStyle = "\x1b[38;5;74m"
  58 
  59     // keyStyle is magenta, and very distinct from normal strings
  60     keyStyle = "\x1b[38;5;99m"
  61 
  62     // nullStyle is a light-gray, just like syntax elements, but the word
  63     // `null` is wide enough to stand out from syntax items at a glance
  64     nullStyle = syntaxStyle
  65 
  66     // numberStyle is a nice green
  67     // numberStyle = "\x1b[38;5;29m"
  68 
  69     // positiveNumberStyle is a nice green
  70     positiveNumberStyle = "\x1b[38;5;29m"
  71 
  72     // negativeNumberStyle is a nice red
  73     negativeNumberStyle = "\x1b[38;5;1m"
  74 
  75     // zeroNumberStyle is a nice blue
  76     zeroNumberStyle = "\x1b[38;5;26m"
  77 
  78     // stringStyle used to be bluish, but it's better to keep it plain,
  79     // which also minimizes how many different colors the output can show
  80     stringStyle = ""
  81 
  82     // stringStyle is bluish, but clearly darker than boolStyle
  83     // stringStyle = "\x1b[38;5;24m"
  84 
  85     // syntaxStyle is a light-gray, not too light, not too dark
  86     syntaxStyle = "\x1b[38;5;248m"
  87 )
  88 
  89 // errNoMoreOutput is a way to successfully quit the app right away, its
  90 // message never shown
  91 var errNoMoreOutput = errors.New(`no more output`)
  92 
  93 func main() {
  94     if len(os.Args) > 1 {
  95         switch os.Args[1] {
  96         case `-h`, `--h`, `-help`, `--help`:
  97             os.Stderr.WriteString(info[1:])
  98             return
  99         }
 100     }
 101 
 102     if len(os.Args) > 2 {
 103         showError(errors.New(`multiple inputs not allowed`))
 104         os.Exit(1)
 105     }
 106 
 107     var err error
 108     if len(os.Args) == 1 || (len(os.Args) == 2 && os.Args[1] == `-`) {
 109         // handle lack of filepath arg, or `-` as the filepath
 110         err = niceJSON(os.Stdout, os.Stdin)
 111     } else {
 112         // handle being given a normal filepath
 113         err = handleFile(os.Stdout, os.Args[1])
 114     }
 115 
 116     if err != nil && err != errNoMoreOutput {
 117         showError(err)
 118         os.Exit(1)
 119     }
 120 }
 121 
 122 // showError standardizes how errors look in this app
 123 func showError(err error) {
 124     os.Stderr.WriteString("\x1b[31m")
 125     os.Stderr.WriteString(err.Error())
 126     os.Stderr.WriteString("\x1b[0m\n")
 127 }
 128 
 129 // writeSpaces does what it says, minimizing calls to write-like funcs
 130 func writeSpaces(w *bufio.Writer, n int) {
 131     const spaces = `                                `
 132     for n >= len(spaces) {
 133         w.WriteString(spaces)
 134         n -= len(spaces)
 135     }
 136     if n > 0 {
 137         w.WriteString(spaces[:n])
 138     }
 139 }
 140 
 141 func handleFile(w io.Writer, path string) error {
 142     // if f := strings.HasPrefix; f(path, `https://`) || f(path, `http://`) {
 143     //  resp, err := http.Get(path)
 144     //  if err != nil {
 145     //      return err
 146     //  }
 147     //  defer resp.Body.Close()
 148     //  return niceJSON(w, resp.Body)
 149     // }
 150 
 151     f, err := os.Open(path)
 152     if err != nil {
 153         // on windows, file-not-found error messages may mention `CreateFile`,
 154         // even when trying to open files in read-only mode
 155         return errors.New(`can't open file named ` + path)
 156     }
 157     defer f.Close()
 158 
 159     return niceJSON(w, f)
 160 }
 161 
 162 func niceJSON(w io.Writer, r io.Reader) error {
 163     bw := bufio.NewWriter(w)
 164     defer bw.Flush()
 165 
 166     dec := json.NewDecoder(r)
 167     // using string-like json.Number values instead of float64 ones avoids
 168     // unneeded reformatting of numbers; reformatting parsed float64 values
 169     // can potentially even drop/change decimals, causing the output not to
 170     // match the input digits exactly, which is best to avoid
 171     dec.UseNumber()
 172 
 173     t, err := dec.Token()
 174     if err == io.EOF {
 175         return errors.New(`empty input isn't valid JSON`)
 176     }
 177     if err != nil {
 178         return err
 179     }
 180 
 181     if err := handleToken(bw, dec, t, 0, 0); err != nil {
 182         return err
 183     }
 184     // don't forget to end the last output line
 185     bw.WriteByte('\n')
 186 
 187     if _, err := dec.Token(); err != io.EOF {
 188         return errors.New(`unexpected trailing JSON data`)
 189     }
 190     return nil
 191 }
 192 
 193 func handleToken(w *bufio.Writer, d *json.Decoder, t json.Token, pre, level int) error {
 194     switch t := t.(type) {
 195     case json.Delim:
 196         switch t {
 197         case json.Delim('['):
 198             return handleArray(w, d, pre, level)
 199 
 200         case json.Delim('{'):
 201             return handleObject(w, d, pre, level)
 202 
 203         default:
 204             // return fmt.Errorf(`unsupported JSON delimiter %v`, t)
 205             return errors.New(`unsupported JSON delimiter`)
 206         }
 207 
 208     case nil:
 209         return handleNull(w, pre)
 210 
 211     case bool:
 212         return handleBoolean(w, t, pre)
 213 
 214     case string:
 215         return handleString(w, t, pre)
 216 
 217     case json.Number:
 218         return handleNumber(w, t, pre)
 219 
 220     default:
 221         // return fmt.Errorf(`unsupported token type %T`, t)
 222         return errors.New(`unsupported token type`)
 223     }
 224 }
 225 
 226 func handleArray(w *bufio.Writer, d *json.Decoder, pre, level int) error {
 227     for i := 0; true; i++ {
 228         t, err := d.Token()
 229         if err != nil {
 230             return err
 231         }
 232 
 233         if t == json.Delim(']') {
 234             if i == 0 {
 235                 writeSpaces(w, indent*pre)
 236                 w.WriteString(syntaxStyle + "[]\x1b[0m")
 237             } else {
 238                 w.WriteString("\n")
 239                 writeSpaces(w, indent*level)
 240                 w.WriteString(syntaxStyle + "]\x1b[0m")
 241             }
 242             return nil
 243         }
 244 
 245         if i == 0 {
 246             writeSpaces(w, indent*pre)
 247             w.WriteString(syntaxStyle + "[\x1b[0m\n")
 248         } else {
 249             // this is a good spot to check for early-quit opportunities
 250             w.WriteString(syntaxStyle + ",\x1b[0m\n")
 251             if err := w.Flush(); err != nil {
 252                 // a write error may be the consequence of stdout being closed,
 253                 // perhaps by another app along a pipe
 254                 return errNoMoreOutput
 255             }
 256         }
 257 
 258         if err := handleToken(w, d, t, level+1, level+1); err != nil {
 259             return err
 260         }
 261     }
 262 
 263     // make the compiler happy
 264     return nil
 265 }
 266 
 267 func handleBoolean(w *bufio.Writer, b bool, pre int) error {
 268     writeSpaces(w, indent*pre)
 269     if b {
 270         w.WriteString(boolStyle + "true\x1b[0m")
 271     } else {
 272         w.WriteString(boolStyle + "false\x1b[0m")
 273     }
 274     return nil
 275 }
 276 
 277 func handleKey(w *bufio.Writer, s string, pre int) error {
 278     writeSpaces(w, indent*pre)
 279     w.WriteString(syntaxStyle + "\"\x1b[0m" + keyStyle)
 280     w.WriteString(s)
 281     w.WriteString(syntaxStyle + "\":\x1b[0m ")
 282     return nil
 283 }
 284 
 285 func handleNull(w *bufio.Writer, pre int) error {
 286     writeSpaces(w, indent*pre)
 287     w.WriteString(nullStyle + "null\x1b[0m")
 288     return nil
 289 }
 290 
 291 // func handleNumber(w *bufio.Writer, n json.Number, pre int) error {
 292 //  writeSpaces(w, indent*pre)
 293 //  w.WriteString(numberStyle)
 294 //  w.WriteString(n.String())
 295 //  w.WriteString("\x1b[0m")
 296 //  return nil
 297 // }
 298 
 299 func handleNumber(w *bufio.Writer, n json.Number, pre int) error {
 300     writeSpaces(w, indent*pre)
 301     f, _ := n.Float64()
 302     if f > 0 {
 303         w.WriteString(positiveNumberStyle)
 304     } else if f < 0 {
 305         w.WriteString(negativeNumberStyle)
 306     } else {
 307         w.WriteString(zeroNumberStyle)
 308     }
 309     w.WriteString(n.String())
 310     w.WriteString("\x1b[0m")
 311     return nil
 312 }
 313 
 314 func handleObject(w *bufio.Writer, d *json.Decoder, pre, level int) error {
 315     for i := 0; true; i++ {
 316         t, err := d.Token()
 317         if err != nil {
 318             return err
 319         }
 320 
 321         if t == json.Delim('}') {
 322             if i == 0 {
 323                 writeSpaces(w, indent*pre)
 324                 w.WriteString(syntaxStyle + "{}\x1b[0m")
 325             } else {
 326                 w.WriteString("\n")
 327                 writeSpaces(w, indent*level)
 328                 w.WriteString(syntaxStyle + "}\x1b[0m")
 329             }
 330             return nil
 331         }
 332 
 333         if i == 0 {
 334             writeSpaces(w, indent*pre)
 335             w.WriteString(syntaxStyle + "{\x1b[0m\n")
 336         } else {
 337             // this is a good spot to check for early-quit opportunities
 338             w.WriteString(syntaxStyle + ",\x1b[0m\n")
 339             if err := w.Flush(); err != nil {
 340                 // a write error may be the consequence of stdout being closed,
 341                 // perhaps by another app along a pipe
 342                 return errNoMoreOutput
 343             }
 344         }
 345 
 346         // the stdlib's JSON parser is supposed to complain about non-string
 347         // keys anyway, but make sure just in case
 348         k, ok := t.(string)
 349         if !ok {
 350             return errors.New(`expected key to be a string`)
 351         }
 352         if err := handleKey(w, k, level+1); err != nil {
 353             return err
 354         }
 355 
 356         // handle value
 357         t, err = d.Token()
 358         if err != nil {
 359             return err
 360         }
 361         if err := handleToken(w, d, t, 0, level+1); err != nil {
 362             return err
 363         }
 364     }
 365 
 366     // make the compiler happy
 367     return nil
 368 }
 369 
 370 func needsEscaping(s string) bool {
 371     for _, r := range s {
 372         switch r {
 373         case '"', '\\', '\t', '\r', '\n':
 374             return true
 375         }
 376     }
 377     return false
 378 }
 379 
 380 func handleString(w *bufio.Writer, s string, pre int) error {
 381     writeSpaces(w, indent*pre)
 382     w.WriteString(syntaxStyle + "\"\x1b[0m" + stringStyle)
 383     if !needsEscaping(s) {
 384         w.WriteString(s)
 385     } else {
 386         escapeString(w, s)
 387     }
 388     w.WriteString(syntaxStyle + "\"\x1b[0m")
 389     return nil
 390 }
 391 
 392 func escapeString(w *bufio.Writer, s string) {
 393     for _, r := range s {
 394         switch r {
 395         case '"', '\\':
 396             w.WriteByte('\\')
 397             w.WriteRune(r)
 398         case '\t':
 399             w.WriteByte('\\')
 400             w.WriteByte('t')
 401         case '\r':
 402             w.WriteByte('\\')
 403             w.WriteByte('r')
 404         case '\n':
 405             w.WriteByte('\\')
 406             w.WriteByte('n')
 407         default:
 408             w.WriteRune(r)
 409         }
 410     }
 411 }