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             _, err = w.WriteString(syntaxStyle + ",\x1b[0m\n")
 251             if err != nil {
 252                 return errNoMoreOutput
 253             }
 254         }
 255 
 256         if err := handleToken(w, d, t, level+1, level+1); err != nil {
 257             return err
 258         }
 259     }
 260 
 261     // make the compiler happy
 262     return nil
 263 }
 264 
 265 func handleBoolean(w *bufio.Writer, b bool, pre int) error {
 266     writeSpaces(w, indent*pre)
 267     if b {
 268         w.WriteString(boolStyle + "true\x1b[0m")
 269     } else {
 270         w.WriteString(boolStyle + "false\x1b[0m")
 271     }
 272     return nil
 273 }
 274 
 275 func handleKey(w *bufio.Writer, s string, pre int) error {
 276     writeSpaces(w, indent*pre)
 277     w.WriteString(syntaxStyle + "\"\x1b[0m" + keyStyle)
 278     w.WriteString(s)
 279     w.WriteString(syntaxStyle + "\":\x1b[0m ")
 280     return nil
 281 }
 282 
 283 func handleNull(w *bufio.Writer, pre int) error {
 284     writeSpaces(w, indent*pre)
 285     w.WriteString(nullStyle + "null\x1b[0m")
 286     return nil
 287 }
 288 
 289 // func handleNumber(w *bufio.Writer, n json.Number, pre int) error {
 290 //  writeSpaces(w, indent*pre)
 291 //  w.WriteString(numberStyle)
 292 //  w.WriteString(n.String())
 293 //  w.WriteString("\x1b[0m")
 294 //  return nil
 295 // }
 296 
 297 func handleNumber(w *bufio.Writer, n json.Number, pre int) error {
 298     writeSpaces(w, indent*pre)
 299     f, _ := n.Float64()
 300     if f > 0 {
 301         w.WriteString(positiveNumberStyle)
 302     } else if f < 0 {
 303         w.WriteString(negativeNumberStyle)
 304     } else {
 305         w.WriteString(zeroNumberStyle)
 306     }
 307     w.WriteString(n.String())
 308     w.WriteString("\x1b[0m")
 309     return nil
 310 }
 311 
 312 func handleObject(w *bufio.Writer, d *json.Decoder, pre, level int) error {
 313     for i := 0; true; i++ {
 314         t, err := d.Token()
 315         if err != nil {
 316             return err
 317         }
 318 
 319         if t == json.Delim('}') {
 320             if i == 0 {
 321                 writeSpaces(w, indent*pre)
 322                 w.WriteString(syntaxStyle + "{}\x1b[0m")
 323             } else {
 324                 w.WriteString("\n")
 325                 writeSpaces(w, indent*level)
 326                 w.WriteString(syntaxStyle + "}\x1b[0m")
 327             }
 328             return nil
 329         }
 330 
 331         if i == 0 {
 332             writeSpaces(w, indent*pre)
 333             w.WriteString(syntaxStyle + "{\x1b[0m\n")
 334         } else {
 335             // this is a good spot to check for early-quit opportunities
 336             _, err = w.WriteString(syntaxStyle + ",\x1b[0m\n")
 337             if err != nil {
 338                 return errNoMoreOutput
 339             }
 340         }
 341 
 342         // the stdlib's JSON parser is supposed to complain about non-string
 343         // keys anyway, but make sure just in case
 344         k, ok := t.(string)
 345         if !ok {
 346             return errors.New(`expected key to be a string`)
 347         }
 348         if err := handleKey(w, k, level+1); err != nil {
 349             return err
 350         }
 351 
 352         // handle value
 353         t, err = d.Token()
 354         if err != nil {
 355             return err
 356         }
 357         if err := handleToken(w, d, t, 0, level+1); err != nil {
 358             return err
 359         }
 360     }
 361 
 362     // make the compiler happy
 363     return nil
 364 }
 365 
 366 func needsEscaping(s string) bool {
 367     for _, r := range s {
 368         switch r {
 369         case '"', '\\', '\t', '\r', '\n':
 370             return true
 371         }
 372     }
 373     return false
 374 }
 375 
 376 func handleString(w *bufio.Writer, s string, pre int) error {
 377     writeSpaces(w, indent*pre)
 378     w.WriteString(syntaxStyle + "\"\x1b[0m" + stringStyle)
 379     if !needsEscaping(s) {
 380         w.WriteString(s)
 381     } else {
 382         escapeString(w, s)
 383     }
 384     w.WriteString(syntaxStyle + "\"\x1b[0m")
 385     return nil
 386 }
 387 
 388 func escapeString(w *bufio.Writer, s string) {
 389     for _, r := range s {
 390         switch r {
 391         case '"', '\\':
 392             w.WriteByte('\\')
 393             w.WriteRune(r)
 394         case '\t':
 395             w.WriteByte('\\')
 396             w.WriteByte('t')
 397         case '\r':
 398             w.WriteByte('\\')
 399             w.WriteByte('r')
 400         case '\n':
 401             w.WriteByte('\\')
 402             w.WriteByte('n')
 403         default:
 404             w.WriteRune(r)
 405         }
 406     }
 407 }