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;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 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 _, err = w.WriteString(syntaxStyle + ",\x1b[0m\n") 337 if err != nil { 338 return errNoMoreOutput 339 } 340 } 341 342 // the stdlib's JSON parser is supposed to complain about non-string 343 // keys anyway, but make sure just in case 344 k, ok := t.(string) 345 if !ok { 346 return errors.New(`expected key to be a string`) 347 } 348 if err := handleKey(w, k, level+1); err != nil { 349 return err 350 } 351 352 // handle value 353 t, err = d.Token() 354 if err != nil { 355 return err 356 } 357 if err := handleToken(w, d, t, 0, level+1); err != nil { 358 return err 359 } 360 } 361 362 // make the compiler happy 363 return nil 364 } 365 366 func needsEscaping(s string) bool { 367 for _, r := range s { 368 switch r { 369 case '"', '\\', '\t', '\r', '\n': 370 return true 371 } 372 } 373 return false 374 } 375 376 func handleString(w *bufio.Writer, s string, pre int) error { 377 writeSpaces(w, indent*pre) 378 w.WriteString(syntaxStyle + "\"\x1b[0m" + stringStyle) 379 if !needsEscaping(s) { 380 w.WriteString(s) 381 } else { 382 escapeString(w, s) 383 } 384 w.WriteString(syntaxStyle + "\"\x1b[0m") 385 return nil 386 } 387 388 func escapeString(w *bufio.Writer, s string) { 389 for _, r := range s { 390 switch r { 391 case '"', '\\': 392 w.WriteByte('\\') 393 w.WriteRune(r) 394 case '\t': 395 w.WriteByte('\\') 396 w.WriteByte('t') 397 case '\r': 398 w.WriteByte('\\') 399 w.WriteByte('r') 400 case '\n': 401 w.WriteByte('\\') 402 w.WriteByte('n') 403 default: 404 w.WriteRune(r) 405 } 406 } 407 }