File: njson.go 1 /* 2 The MIT License (MIT) 3 4 Copyright (c) 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 func main() { 81 args := os.Args[1:] 82 83 if len(args) > 0 { 84 switch args[0] { 85 case `-h`, `--h`, `-help`, `--help`: 86 os.Stdout.WriteString(info[1:]) 87 return 88 } 89 } 90 91 if len(args) > 0 && args[0] == `--` { 92 args = args[1:] 93 } 94 95 if len(args) > 1 { 96 showError(errors.New(`multiple inputs not allowed`)) 97 os.Exit(1) 98 } 99 100 // figure out whether input should come from a named file or from stdin 101 name := `-` 102 if len(args) == 1 { 103 name = args[0] 104 } 105 106 var err error 107 if name == `-` { 108 // handle lack of filepath arg, or `-` as the filepath 109 err = niceJSON(os.Stdout, os.Stdin) 110 } else { 111 // handle being given a normal filepath 112 err = handleFile(os.Stdout, os.Args[1]) 113 } 114 115 if err != nil && err != io.EOF { 116 showError(err) 117 os.Exit(1) 118 } 119 } 120 121 // showError standardizes how errors look in this app 122 func showError(err error) { 123 os.Stderr.WriteString(err.Error()) 124 os.Stderr.WriteString("\n") 125 } 126 127 // writeSpaces does what it says, minimizing calls to write-like funcs 128 func writeSpaces(w *bufio.Writer, n int) { 129 const spaces = ` ` 130 for n >= len(spaces) { 131 w.WriteString(spaces) 132 n -= len(spaces) 133 } 134 if n > 0 { 135 w.WriteString(spaces[:n]) 136 } 137 } 138 139 func handleFile(w io.Writer, path string) error { 140 // if f := strings.HasPrefix; f(path, `https://`) || f(path, `http://`) { 141 // resp, err := http.Get(path) 142 // if err != nil { 143 // return err 144 // } 145 // defer resp.Body.Close() 146 // return niceJSON(w, resp.Body) 147 // } 148 149 f, err := os.Open(path) 150 if err != nil { 151 // on windows, file-not-found error messages may mention `CreateFile`, 152 // even when trying to open files in read-only mode 153 return errors.New(`can't open file named ` + path) 154 } 155 defer f.Close() 156 157 return niceJSON(w, f) 158 } 159 160 func niceJSON(w io.Writer, r io.Reader) error { 161 bw := bufio.NewWriter(w) 162 defer bw.Flush() 163 164 dec := json.NewDecoder(r) 165 // using string-like json.Number values instead of float64 ones avoids 166 // unneeded reformatting of numbers; reformatting parsed float64 values 167 // can potentially even drop/change decimals, causing the output not to 168 // match the input digits exactly, which is best to avoid 169 dec.UseNumber() 170 171 t, err := dec.Token() 172 if err == io.EOF { 173 return errors.New(`empty input isn't valid JSON`) 174 } 175 if err != nil { 176 return err 177 } 178 179 if err := handleToken(bw, dec, t, 0, 0); err != nil { 180 return err 181 } 182 // don't forget to end the last output line 183 bw.WriteByte('\n') 184 185 if _, err := dec.Token(); err != io.EOF { 186 return errors.New(`unexpected trailing JSON data`) 187 } 188 return nil 189 } 190 191 func handleToken(w *bufio.Writer, d *json.Decoder, t json.Token, pre, level int) error { 192 switch t := t.(type) { 193 case json.Delim: 194 switch t { 195 case json.Delim('['): 196 return handleArray(w, d, pre, level) 197 198 case json.Delim('{'): 199 return handleObject(w, d, pre, level) 200 201 default: 202 // return fmt.Errorf(`unsupported JSON delimiter %v`, t) 203 return errors.New(`unsupported JSON delimiter`) 204 } 205 206 case nil: 207 return handleNull(w, pre) 208 209 case bool: 210 return handleBoolean(w, t, pre) 211 212 case string: 213 return handleString(w, t, pre) 214 215 case json.Number: 216 return handleNumber(w, t, pre) 217 218 default: 219 // return fmt.Errorf(`unsupported token type %T`, t) 220 return errors.New(`unsupported token type`) 221 } 222 } 223 224 func handleArray(w *bufio.Writer, d *json.Decoder, pre, level int) error { 225 for i := 0; true; i++ { 226 t, err := d.Token() 227 if err != nil { 228 return err 229 } 230 231 if t == json.Delim(']') { 232 if i == 0 { 233 writeSpaces(w, indent*pre) 234 w.WriteString(syntaxStyle + "[]\x1b[0m") 235 } else { 236 w.WriteString("\n") 237 writeSpaces(w, indent*level) 238 w.WriteString(syntaxStyle + "]\x1b[0m") 239 } 240 return nil 241 } 242 243 if i == 0 { 244 writeSpaces(w, indent*pre) 245 w.WriteString(syntaxStyle + "[\x1b[0m\n") 246 } else { 247 // this is a good spot to check for early-quit opportunities 248 w.WriteString(syntaxStyle + ",\x1b[0m\n") 249 if err := w.Flush(); err != nil { 250 // a write error may be the consequence of stdout being closed, 251 // perhaps by another app along a pipe 252 return io.EOF 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 w.WriteString(syntaxStyle + ",\x1b[0m\n") 337 if err := w.Flush(); err != nil { 338 // a write error may be the consequence of stdout being closed, 339 // perhaps by another app along a pipe 340 return io.EOF 341 } 342 } 343 344 // the stdlib's JSON parser is supposed to complain about non-string 345 // keys anyway, but make sure just in case 346 k, ok := t.(string) 347 if !ok { 348 return errors.New(`expected key to be a string`) 349 } 350 if err := handleKey(w, k, level+1); err != nil { 351 return err 352 } 353 354 // handle value 355 t, err = d.Token() 356 if err != nil { 357 return err 358 } 359 if err := handleToken(w, d, t, 0, level+1); err != nil { 360 return err 361 } 362 } 363 364 // make the compiler happy 365 return nil 366 } 367 368 func needsEscaping(s string) bool { 369 for _, r := range s { 370 switch r { 371 case '"', '\\', '\t', '\r', '\n': 372 return true 373 } 374 } 375 return false 376 } 377 378 func handleString(w *bufio.Writer, s string, pre int) error { 379 writeSpaces(w, indent*pre) 380 w.WriteString(syntaxStyle + "\"\x1b[0m" + stringStyle) 381 if !needsEscaping(s) { 382 w.WriteString(s) 383 } else { 384 escapeString(w, s) 385 } 386 w.WriteString(syntaxStyle + "\"\x1b[0m") 387 return nil 388 } 389 390 func escapeString(w *bufio.Writer, s string) { 391 for _, r := range s { 392 switch r { 393 case '"', '\\': 394 w.WriteByte('\\') 395 w.WriteRune(r) 396 case '\t': 397 w.WriteByte('\\') 398 w.WriteByte('t') 399 case '\r': 400 w.WriteByte('\\') 401 w.WriteByte('r') 402 case '\n': 403 w.WriteByte('\\') 404 w.WriteByte('n') 405 default: 406 w.WriteRune(r) 407 } 408 } 409 }