File: nj.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright © 2024 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         writeSpaces(w, indent*(level+1))
 257         if err := handleToken(w, d, t, level, level+1); err != nil {
 258             return err
 259         }
 260     }
 261 
 262     // make the compiler happy
 263     return nil
 264 }
 265 
 266 func handleBoolean(w *bufio.Writer, b bool, pre int) error {
 267     writeSpaces(w, indent*pre)
 268     if b {
 269         w.WriteString(boolStyle + "true\x1b[0m")
 270     } else {
 271         w.WriteString(boolStyle + "false\x1b[0m")
 272     }
 273     return nil
 274 }
 275 
 276 func handleKey(w *bufio.Writer, s string, pre int) error {
 277     writeSpaces(w, indent*pre)
 278     w.WriteString(syntaxStyle + "\"\x1b[0m" + keyStyle)
 279     w.WriteString(s)
 280     w.WriteString(syntaxStyle + "\":\x1b[0m ")
 281     return nil
 282 }
 283 
 284 func handleNull(w *bufio.Writer, pre int) error {
 285     writeSpaces(w, indent*pre)
 286     w.WriteString(nullStyle + "null\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 //  w.WriteString(numberStyle)
 293 //  w.WriteString(n.String())
 294 //  w.WriteString("\x1b[0m")
 295 //  return nil
 296 // }
 297 
 298 func handleNumber(w *bufio.Writer, n json.Number, pre int) error {
 299     writeSpaces(w, indent*pre)
 300     f, _ := n.Float64()
 301     if f > 0 {
 302         w.WriteString(positiveNumberStyle)
 303     } else if f < 0 {
 304         w.WriteString(negativeNumberStyle)
 305     } else {
 306         w.WriteString(zeroNumberStyle)
 307     }
 308     w.WriteString(n.String())
 309     w.WriteString("\x1b[0m")
 310     return nil
 311 }
 312 
 313 func handleObject(w *bufio.Writer, d *json.Decoder, pre, level int) error {
 314     for i := 0; true; i++ {
 315         t, err := d.Token()
 316         if err != nil {
 317             return err
 318         }
 319 
 320         if t == json.Delim('}') {
 321             if i == 0 {
 322                 writeSpaces(w, indent*pre)
 323                 w.WriteString(syntaxStyle + "{}\x1b[0m")
 324             } else {
 325                 w.WriteString("\n")
 326                 writeSpaces(w, indent*level)
 327                 w.WriteString(syntaxStyle + "}\x1b[0m")
 328             }
 329             return nil
 330         }
 331 
 332         if i == 0 {
 333             writeSpaces(w, indent*pre)
 334             w.WriteString(syntaxStyle + "{\x1b[0m\n")
 335         } else {
 336             // this is a good spot to check for early-quit opportunities
 337             _, err = w.WriteString(syntaxStyle + ",\x1b[0m\n")
 338             if err != nil {
 339                 return errNoMoreOutput
 340             }
 341         }
 342 
 343         // the stdlib's JSON parser is supposed to complain about non-string
 344         // keys anyway, but make sure just in case
 345         k, ok := t.(string)
 346         if !ok {
 347             return errors.New(`expected key to be a string`)
 348         }
 349         if err := handleKey(w, k, level+1); err != nil {
 350             return err
 351         }
 352 
 353         // handle value
 354         t, err = d.Token()
 355         if err != nil {
 356             return err
 357         }
 358         if err := handleToken(w, d, t, 0, level+1); err != nil {
 359             return err
 360         }
 361     }
 362 
 363     // make the compiler happy
 364     return nil
 365 }
 366 
 367 func handleString(w *bufio.Writer, s string, pre int) error {
 368     writeSpaces(w, indent*pre)
 369     w.WriteString(syntaxStyle + "\"\x1b[0m" + stringStyle)
 370     w.WriteString(s)
 371     w.WriteString(syntaxStyle + "\"\x1b[0m")
 372     return nil
 373 }