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 }