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 }