File: njson.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright (c) 2026 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 func main() {
  81     args := os.Args[1:]
  82 
  83     if len(args) > 0 {
  84         switch args[0] {
  85         case `-h`, `--h`, `-help`, `--help`:
  86             os.Stdout.WriteString(info[1:])
  87             return
  88         }
  89     }
  90 
  91     if len(args) > 0 && args[0] == `--` {
  92         args = args[1:]
  93     }
  94 
  95     if len(args) > 1 {
  96         showError(errors.New(`multiple inputs not allowed`))
  97         os.Exit(1)
  98     }
  99 
 100     // figure out whether input should come from a named file or from stdin
 101     name := `-`
 102     if len(args) == 1 {
 103         name = args[0]
 104     }
 105 
 106     var err error
 107     if name == `-` {
 108         // handle lack of filepath arg, or `-` as the filepath
 109         err = niceJSON(os.Stdout, os.Stdin)
 110     } else {
 111         // handle being given a normal filepath
 112         err = handleFile(os.Stdout, os.Args[1])
 113     }
 114 
 115     if err != nil && err != io.EOF {
 116         showError(err)
 117         os.Exit(1)
 118     }
 119 }
 120 
 121 // showError standardizes how errors look in this app
 122 func showError(err error) {
 123     os.Stderr.WriteString(err.Error())
 124     os.Stderr.WriteString("\n")
 125 }
 126 
 127 // writeSpaces does what it says, minimizing calls to write-like funcs
 128 func writeSpaces(w *bufio.Writer, n int) {
 129     const spaces = `                                `
 130     for n >= len(spaces) {
 131         w.WriteString(spaces)
 132         n -= len(spaces)
 133     }
 134     if n > 0 {
 135         w.WriteString(spaces[:n])
 136     }
 137 }
 138 
 139 func handleFile(w io.Writer, path string) error {
 140     // if f := strings.HasPrefix; f(path, `https://`) || f(path, `http://`) {
 141     //  resp, err := http.Get(path)
 142     //  if err != nil {
 143     //      return err
 144     //  }
 145     //  defer resp.Body.Close()
 146     //  return niceJSON(w, resp.Body)
 147     // }
 148 
 149     f, err := os.Open(path)
 150     if err != nil {
 151         // on windows, file-not-found error messages may mention `CreateFile`,
 152         // even when trying to open files in read-only mode
 153         return errors.New(`can't open file named ` + path)
 154     }
 155     defer f.Close()
 156 
 157     return niceJSON(w, f)
 158 }
 159 
 160 func niceJSON(w io.Writer, r io.Reader) error {
 161     bw := bufio.NewWriter(w)
 162     defer bw.Flush()
 163 
 164     dec := json.NewDecoder(r)
 165     // using string-like json.Number values instead of float64 ones avoids
 166     // unneeded reformatting of numbers; reformatting parsed float64 values
 167     // can potentially even drop/change decimals, causing the output not to
 168     // match the input digits exactly, which is best to avoid
 169     dec.UseNumber()
 170 
 171     t, err := dec.Token()
 172     if err == io.EOF {
 173         return errors.New(`empty input isn't valid JSON`)
 174     }
 175     if err != nil {
 176         return err
 177     }
 178 
 179     if err := handleToken(bw, dec, t, 0, 0); err != nil {
 180         return err
 181     }
 182     // don't forget to end the last output line
 183     bw.WriteByte('\n')
 184 
 185     if _, err := dec.Token(); err != io.EOF {
 186         return errors.New(`unexpected trailing JSON data`)
 187     }
 188     return nil
 189 }
 190 
 191 func handleToken(w *bufio.Writer, d *json.Decoder, t json.Token, pre, level int) error {
 192     switch t := t.(type) {
 193     case json.Delim:
 194         switch t {
 195         case json.Delim('['):
 196             return handleArray(w, d, pre, level)
 197 
 198         case json.Delim('{'):
 199             return handleObject(w, d, pre, level)
 200 
 201         default:
 202             // return fmt.Errorf(`unsupported JSON delimiter %v`, t)
 203             return errors.New(`unsupported JSON delimiter`)
 204         }
 205 
 206     case nil:
 207         return handleNull(w, pre)
 208 
 209     case bool:
 210         return handleBoolean(w, t, pre)
 211 
 212     case string:
 213         return handleString(w, t, pre)
 214 
 215     case json.Number:
 216         return handleNumber(w, t, pre)
 217 
 218     default:
 219         // return fmt.Errorf(`unsupported token type %T`, t)
 220         return errors.New(`unsupported token type`)
 221     }
 222 }
 223 
 224 func handleArray(w *bufio.Writer, d *json.Decoder, pre, level int) error {
 225     for i := 0; true; i++ {
 226         t, err := d.Token()
 227         if err != nil {
 228             return err
 229         }
 230 
 231         if t == json.Delim(']') {
 232             if i == 0 {
 233                 writeSpaces(w, indent*pre)
 234                 w.WriteString(syntaxStyle + "[]\x1b[0m")
 235             } else {
 236                 w.WriteString("\n")
 237                 writeSpaces(w, indent*level)
 238                 w.WriteString(syntaxStyle + "]\x1b[0m")
 239             }
 240             return nil
 241         }
 242 
 243         if i == 0 {
 244             writeSpaces(w, indent*pre)
 245             w.WriteString(syntaxStyle + "[\x1b[0m\n")
 246         } else {
 247             // this is a good spot to check for early-quit opportunities
 248             w.WriteString(syntaxStyle + ",\x1b[0m\n")
 249             if err := w.Flush(); err != nil {
 250                 // a write error may be the consequence of stdout being closed,
 251                 // perhaps by another app along a pipe
 252                 return io.EOF
 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             w.WriteString(syntaxStyle + ",\x1b[0m\n")
 337             if err := w.Flush(); err != nil {
 338                 // a write error may be the consequence of stdout being closed,
 339                 // perhaps by another app along a pipe
 340                 return io.EOF
 341             }
 342         }
 343 
 344         // the stdlib's JSON parser is supposed to complain about non-string
 345         // keys anyway, but make sure just in case
 346         k, ok := t.(string)
 347         if !ok {
 348             return errors.New(`expected key to be a string`)
 349         }
 350         if err := handleKey(w, k, level+1); err != nil {
 351             return err
 352         }
 353 
 354         // handle value
 355         t, err = d.Token()
 356         if err != nil {
 357             return err
 358         }
 359         if err := handleToken(w, d, t, 0, level+1); err != nil {
 360             return err
 361         }
 362     }
 363 
 364     // make the compiler happy
 365     return nil
 366 }
 367 
 368 func needsEscaping(s string) bool {
 369     for _, r := range s {
 370         switch r {
 371         case '"', '\\', '\t', '\r', '\n':
 372             return true
 373         }
 374     }
 375     return false
 376 }
 377 
 378 func handleString(w *bufio.Writer, s string, pre int) error {
 379     writeSpaces(w, indent*pre)
 380     w.WriteString(syntaxStyle + "\"\x1b[0m" + stringStyle)
 381     if !needsEscaping(s) {
 382         w.WriteString(s)
 383     } else {
 384         escapeString(w, s)
 385     }
 386     w.WriteString(syntaxStyle + "\"\x1b[0m")
 387     return nil
 388 }
 389 
 390 func escapeString(w *bufio.Writer, s string) {
 391     for _, r := range s {
 392         switch r {
 393         case '"', '\\':
 394             w.WriteByte('\\')
 395             w.WriteRune(r)
 396         case '\t':
 397             w.WriteByte('\\')
 398             w.WriteByte('t')
 399         case '\r':
 400             w.WriteByte('\\')
 401             w.WriteByte('r')
 402         case '\n':
 403             w.WriteByte('\\')
 404             w.WriteByte('n')
 405         default:
 406             w.WriteRune(r)
 407         }
 408     }
 409 }