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