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