File: njson.go 1 /* 2 The MIT License (MIT) 3 4 Copyright © 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 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.Stderr.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("\x1b[31m") 116 os.Stderr.WriteString(err.Error()) 117 os.Stderr.WriteString("\x1b[0m\n") 118 } 119 120 // writeSpaces does what it says, minimizing calls to write-like funcs 121 func writeSpaces(w *bufio.Writer, n int) { 122 const spaces = ` ` 123 for n >= len(spaces) { 124 w.WriteString(spaces) 125 n -= len(spaces) 126 } 127 if n > 0 { 128 w.WriteString(spaces[:n]) 129 } 130 } 131 132 func handleFile(w io.Writer, path string) error { 133 // if f := strings.HasPrefix; f(path, `https://`) || f(path, `http://`) { 134 // resp, err := http.Get(path) 135 // if err != nil { 136 // return err 137 // } 138 // defer resp.Body.Close() 139 // return niceJSON(w, resp.Body) 140 // } 141 142 f, err := os.Open(path) 143 if err != nil { 144 // on windows, file-not-found error messages may mention `CreateFile`, 145 // even when trying to open files in read-only mode 146 return errors.New(`can't open file named ` + path) 147 } 148 defer f.Close() 149 150 return niceJSON(w, f) 151 } 152 153 func niceJSON(w io.Writer, r io.Reader) error { 154 bw := bufio.NewWriter(w) 155 defer bw.Flush() 156 157 dec := json.NewDecoder(r) 158 // using string-like json.Number values instead of float64 ones avoids 159 // unneeded reformatting of numbers; reformatting parsed float64 values 160 // can potentially even drop/change decimals, causing the output not to 161 // match the input digits exactly, which is best to avoid 162 dec.UseNumber() 163 164 t, err := dec.Token() 165 if err == io.EOF { 166 return errors.New(`empty input isn't valid JSON`) 167 } 168 if err != nil { 169 return err 170 } 171 172 if err := handleToken(bw, dec, t, 0, 0); err != nil { 173 return err 174 } 175 // don't forget to end the last output line 176 bw.WriteByte('\n') 177 178 if _, err := dec.Token(); err != io.EOF { 179 return errors.New(`unexpected trailing JSON data`) 180 } 181 return nil 182 } 183 184 func handleToken(w *bufio.Writer, d *json.Decoder, t json.Token, pre, level int) error { 185 switch t := t.(type) { 186 case json.Delim: 187 switch t { 188 case json.Delim('['): 189 return handleArray(w, d, pre, level) 190 191 case json.Delim('{'): 192 return handleObject(w, d, pre, level) 193 194 default: 195 // return fmt.Errorf(`unsupported JSON delimiter %v`, t) 196 return errors.New(`unsupported JSON delimiter`) 197 } 198 199 case nil: 200 return handleNull(w, pre) 201 202 case bool: 203 return handleBoolean(w, t, pre) 204 205 case string: 206 return handleString(w, t, pre) 207 208 case json.Number: 209 return handleNumber(w, t, pre) 210 211 default: 212 // return fmt.Errorf(`unsupported token type %T`, t) 213 return errors.New(`unsupported token type`) 214 } 215 } 216 217 func handleArray(w *bufio.Writer, d *json.Decoder, pre, level int) error { 218 for i := 0; true; i++ { 219 t, err := d.Token() 220 if err != nil { 221 return err 222 } 223 224 if t == json.Delim(']') { 225 if i == 0 { 226 writeSpaces(w, indent*pre) 227 w.WriteString(syntaxStyle + "[]\x1b[0m") 228 } else { 229 w.WriteString("\n") 230 writeSpaces(w, indent*level) 231 w.WriteString(syntaxStyle + "]\x1b[0m") 232 } 233 return nil 234 } 235 236 if i == 0 { 237 writeSpaces(w, indent*pre) 238 w.WriteString(syntaxStyle + "[\x1b[0m\n") 239 } else { 240 // this is a good spot to check for early-quit opportunities 241 w.WriteString(syntaxStyle + ",\x1b[0m\n") 242 if err := w.Flush(); err != nil { 243 // a write error may be the consequence of stdout being closed, 244 // perhaps by another app along a pipe 245 return errNoMoreOutput 246 } 247 } 248 249 if err := handleToken(w, d, t, level+1, level+1); err != nil { 250 return err 251 } 252 } 253 254 // make the compiler happy 255 return nil 256 } 257 258 func handleBoolean(w *bufio.Writer, b bool, pre int) error { 259 writeSpaces(w, indent*pre) 260 if b { 261 w.WriteString(boolStyle + "true\x1b[0m") 262 } else { 263 w.WriteString(boolStyle + "false\x1b[0m") 264 } 265 return nil 266 } 267 268 func handleKey(w *bufio.Writer, s string, pre int) error { 269 writeSpaces(w, indent*pre) 270 w.WriteString(syntaxStyle + "\"\x1b[0m" + keyStyle) 271 w.WriteString(s) 272 w.WriteString(syntaxStyle + "\":\x1b[0m ") 273 return nil 274 } 275 276 func handleNull(w *bufio.Writer, pre int) error { 277 writeSpaces(w, indent*pre) 278 w.WriteString(nullStyle + "null\x1b[0m") 279 return nil 280 } 281 282 // func handleNumber(w *bufio.Writer, n json.Number, pre int) error { 283 // writeSpaces(w, indent*pre) 284 // w.WriteString(numberStyle) 285 // w.WriteString(n.String()) 286 // w.WriteString("\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 f, _ := n.Float64() 293 if f > 0 { 294 w.WriteString(positiveNumberStyle) 295 } else if f < 0 { 296 w.WriteString(negativeNumberStyle) 297 } else { 298 w.WriteString(zeroNumberStyle) 299 } 300 w.WriteString(n.String()) 301 w.WriteString("\x1b[0m") 302 return nil 303 } 304 305 func handleObject(w *bufio.Writer, d *json.Decoder, pre, level int) error { 306 for i := 0; true; i++ { 307 t, err := d.Token() 308 if err != nil { 309 return err 310 } 311 312 if t == json.Delim('}') { 313 if i == 0 { 314 writeSpaces(w, indent*pre) 315 w.WriteString(syntaxStyle + "{}\x1b[0m") 316 } else { 317 w.WriteString("\n") 318 writeSpaces(w, indent*level) 319 w.WriteString(syntaxStyle + "}\x1b[0m") 320 } 321 return nil 322 } 323 324 if i == 0 { 325 writeSpaces(w, indent*pre) 326 w.WriteString(syntaxStyle + "{\x1b[0m\n") 327 } else { 328 // this is a good spot to check for early-quit opportunities 329 w.WriteString(syntaxStyle + ",\x1b[0m\n") 330 if err := w.Flush(); err != nil { 331 // a write error may be the consequence of stdout being closed, 332 // perhaps by another app along a pipe 333 return errNoMoreOutput 334 } 335 } 336 337 // the stdlib's JSON parser is supposed to complain about non-string 338 // keys anyway, but make sure just in case 339 k, ok := t.(string) 340 if !ok { 341 return errors.New(`expected key to be a string`) 342 } 343 if err := handleKey(w, k, level+1); err != nil { 344 return err 345 } 346 347 // handle value 348 t, err = d.Token() 349 if err != nil { 350 return err 351 } 352 if err := handleToken(w, d, t, 0, level+1); err != nil { 353 return err 354 } 355 } 356 357 // make the compiler happy 358 return nil 359 } 360 361 func needsEscaping(s string) bool { 362 for _, r := range s { 363 switch r { 364 case '"', '\\', '\t', '\r', '\n': 365 return true 366 } 367 } 368 return false 369 } 370 371 func handleString(w *bufio.Writer, s string, pre int) error { 372 writeSpaces(w, indent*pre) 373 w.WriteString(syntaxStyle + "\"\x1b[0m" + stringStyle) 374 if !needsEscaping(s) { 375 w.WriteString(s) 376 } else { 377 escapeString(w, s) 378 } 379 w.WriteString(syntaxStyle + "\"\x1b[0m") 380 return nil 381 } 382 383 func escapeString(w *bufio.Writer, s string) { 384 for _, r := range s { 385 switch r { 386 case '"', '\\': 387 w.WriteByte('\\') 388 w.WriteRune(r) 389 case '\t': 390 w.WriteByte('\\') 391 w.WriteByte('t') 392 case '\r': 393 w.WriteByte('\\') 394 w.WriteByte('r') 395 case '\n': 396 w.WriteByte('\\') 397 w.WriteByte('n') 398 default: 399 w.WriteRune(r) 400 } 401 } 402 }