File: njson.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright © 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 // 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.Stdout.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(err.Error())
 116     os.Stderr.WriteString("\n")
 117 }
 118 
 119 // writeSpaces does what it says, minimizing calls to write-like funcs
 120 func writeSpaces(w *bufio.Writer, n int) {
 121     const spaces = `                                `
 122     for n >= len(spaces) {
 123         w.WriteString(spaces)
 124         n -= len(spaces)
 125     }
 126     if n > 0 {
 127         w.WriteString(spaces[:n])
 128     }
 129 }
 130 
 131 func handleFile(w io.Writer, path string) error {
 132     // if f := strings.HasPrefix; f(path, `https://`) || f(path, `http://`) {
 133     //  resp, err := http.Get(path)
 134     //  if err != nil {
 135     //      return err
 136     //  }
 137     //  defer resp.Body.Close()
 138     //  return niceJSON(w, resp.Body)
 139     // }
 140 
 141     f, err := os.Open(path)
 142     if err != nil {
 143         // on windows, file-not-found error messages may mention `CreateFile`,
 144         // even when trying to open files in read-only mode
 145         return errors.New(`can't open file named ` + path)
 146     }
 147     defer f.Close()
 148 
 149     return niceJSON(w, f)
 150 }
 151 
 152 func niceJSON(w io.Writer, r io.Reader) error {
 153     bw := bufio.NewWriter(w)
 154     defer bw.Flush()
 155 
 156     dec := json.NewDecoder(r)
 157     // using string-like json.Number values instead of float64 ones avoids
 158     // unneeded reformatting of numbers; reformatting parsed float64 values
 159     // can potentially even drop/change decimals, causing the output not to
 160     // match the input digits exactly, which is best to avoid
 161     dec.UseNumber()
 162 
 163     t, err := dec.Token()
 164     if err == io.EOF {
 165         return errors.New(`empty input isn't valid JSON`)
 166     }
 167     if err != nil {
 168         return err
 169     }
 170 
 171     if err := handleToken(bw, dec, t, 0, 0); err != nil {
 172         return err
 173     }
 174     // don't forget to end the last output line
 175     bw.WriteByte('\n')
 176 
 177     if _, err := dec.Token(); err != io.EOF {
 178         return errors.New(`unexpected trailing JSON data`)
 179     }
 180     return nil
 181 }
 182 
 183 func handleToken(w *bufio.Writer, d *json.Decoder, t json.Token, pre, level int) error {
 184     switch t := t.(type) {
 185     case json.Delim:
 186         switch t {
 187         case json.Delim('['):
 188             return handleArray(w, d, pre, level)
 189 
 190         case json.Delim('{'):
 191             return handleObject(w, d, pre, level)
 192 
 193         default:
 194             // return fmt.Errorf(`unsupported JSON delimiter %v`, t)
 195             return errors.New(`unsupported JSON delimiter`)
 196         }
 197 
 198     case nil:
 199         return handleNull(w, pre)
 200 
 201     case bool:
 202         return handleBoolean(w, t, pre)
 203 
 204     case string:
 205         return handleString(w, t, pre)
 206 
 207     case json.Number:
 208         return handleNumber(w, t, pre)
 209 
 210     default:
 211         // return fmt.Errorf(`unsupported token type %T`, t)
 212         return errors.New(`unsupported token type`)
 213     }
 214 }
 215 
 216 func handleArray(w *bufio.Writer, d *json.Decoder, pre, level int) error {
 217     for i := 0; true; i++ {
 218         t, err := d.Token()
 219         if err != nil {
 220             return err
 221         }
 222 
 223         if t == json.Delim(']') {
 224             if i == 0 {
 225                 writeSpaces(w, indent*pre)
 226                 w.WriteString(syntaxStyle + "[]\x1b[0m")
 227             } else {
 228                 w.WriteString("\n")
 229                 writeSpaces(w, indent*level)
 230                 w.WriteString(syntaxStyle + "]\x1b[0m")
 231             }
 232             return nil
 233         }
 234 
 235         if i == 0 {
 236             writeSpaces(w, indent*pre)
 237             w.WriteString(syntaxStyle + "[\x1b[0m\n")
 238         } else {
 239             // this is a good spot to check for early-quit opportunities
 240             w.WriteString(syntaxStyle + ",\x1b[0m\n")
 241             if err := w.Flush(); err != nil {
 242                 // a write error may be the consequence of stdout being closed,
 243                 // perhaps by another app along a pipe
 244                 return errNoMoreOutput
 245             }
 246         }
 247 
 248         if err := handleToken(w, d, t, level+1, level+1); err != nil {
 249             return err
 250         }
 251     }
 252 
 253     // make the compiler happy
 254     return nil
 255 }
 256 
 257 func handleBoolean(w *bufio.Writer, b bool, pre int) error {
 258     writeSpaces(w, indent*pre)
 259     if b {
 260         w.WriteString(boolStyle + "true\x1b[0m")
 261     } else {
 262         w.WriteString(boolStyle + "false\x1b[0m")
 263     }
 264     return nil
 265 }
 266 
 267 func handleKey(w *bufio.Writer, s string, pre int) error {
 268     writeSpaces(w, indent*pre)
 269     w.WriteString(syntaxStyle + "\"\x1b[0m" + keyStyle)
 270     w.WriteString(s)
 271     w.WriteString(syntaxStyle + "\":\x1b[0m ")
 272     return nil
 273 }
 274 
 275 func handleNull(w *bufio.Writer, pre int) error {
 276     writeSpaces(w, indent*pre)
 277     w.WriteString(nullStyle + "null\x1b[0m")
 278     return nil
 279 }
 280 
 281 // func handleNumber(w *bufio.Writer, n json.Number, pre int) error {
 282 //  writeSpaces(w, indent*pre)
 283 //  w.WriteString(numberStyle)
 284 //  w.WriteString(n.String())
 285 //  w.WriteString("\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     f, _ := n.Float64()
 292     if f > 0 {
 293         w.WriteString(positiveNumberStyle)
 294     } else if f < 0 {
 295         w.WriteString(negativeNumberStyle)
 296     } else {
 297         w.WriteString(zeroNumberStyle)
 298     }
 299     w.WriteString(n.String())
 300     w.WriteString("\x1b[0m")
 301     return nil
 302 }
 303 
 304 func handleObject(w *bufio.Writer, d *json.Decoder, pre, level int) error {
 305     for i := 0; true; i++ {
 306         t, err := d.Token()
 307         if err != nil {
 308             return err
 309         }
 310 
 311         if t == json.Delim('}') {
 312             if i == 0 {
 313                 writeSpaces(w, indent*pre)
 314                 w.WriteString(syntaxStyle + "{}\x1b[0m")
 315             } else {
 316                 w.WriteString("\n")
 317                 writeSpaces(w, indent*level)
 318                 w.WriteString(syntaxStyle + "}\x1b[0m")
 319             }
 320             return nil
 321         }
 322 
 323         if i == 0 {
 324             writeSpaces(w, indent*pre)
 325             w.WriteString(syntaxStyle + "{\x1b[0m\n")
 326         } else {
 327             // this is a good spot to check for early-quit opportunities
 328             w.WriteString(syntaxStyle + ",\x1b[0m\n")
 329             if err := w.Flush(); err != nil {
 330                 // a write error may be the consequence of stdout being closed,
 331                 // perhaps by another app along a pipe
 332                 return errNoMoreOutput
 333             }
 334         }
 335 
 336         // the stdlib's JSON parser is supposed to complain about non-string
 337         // keys anyway, but make sure just in case
 338         k, ok := t.(string)
 339         if !ok {
 340             return errors.New(`expected key to be a string`)
 341         }
 342         if err := handleKey(w, k, level+1); err != nil {
 343             return err
 344         }
 345 
 346         // handle value
 347         t, err = d.Token()
 348         if err != nil {
 349             return err
 350         }
 351         if err := handleToken(w, d, t, 0, level+1); err != nil {
 352             return err
 353         }
 354     }
 355 
 356     // make the compiler happy
 357     return nil
 358 }
 359 
 360 func needsEscaping(s string) bool {
 361     for _, r := range s {
 362         switch r {
 363         case '"', '\\', '\t', '\r', '\n':
 364             return true
 365         }
 366     }
 367     return false
 368 }
 369 
 370 func handleString(w *bufio.Writer, s string, pre int) error {
 371     writeSpaces(w, indent*pre)
 372     w.WriteString(syntaxStyle + "\"\x1b[0m" + stringStyle)
 373     if !needsEscaping(s) {
 374         w.WriteString(s)
 375     } else {
 376         escapeString(w, s)
 377     }
 378     w.WriteString(syntaxStyle + "\"\x1b[0m")
 379     return nil
 380 }
 381 
 382 func escapeString(w *bufio.Writer, s string) {
 383     for _, r := range s {
 384         switch r {
 385         case '"', '\\':
 386             w.WriteByte('\\')
 387             w.WriteRune(r)
 388         case '\t':
 389             w.WriteByte('\\')
 390             w.WriteByte('t')
 391         case '\r':
 392             w.WriteByte('\\')
 393             w.WriteByte('r')
 394         case '\n':
 395             w.WriteByte('\\')
 396             w.WriteByte('n')
 397         default:
 398             w.WriteRune(r)
 399         }
 400     }
 401 }