File: nj.go 1 /* 2 The MIT License (MIT) 3 4 Copyright © 2024 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 writeSpaces(w, indent*(level+1)) 257 if err := handleToken(w, d, t, level, level+1); err != nil { 258 return err 259 } 260 } 261 262 // make the compiler happy 263 return nil 264 } 265 266 func handleBoolean(w *bufio.Writer, b bool, pre int) error { 267 writeSpaces(w, indent*pre) 268 if b { 269 w.WriteString(boolStyle + "true\x1b[0m") 270 } else { 271 w.WriteString(boolStyle + "false\x1b[0m") 272 } 273 return nil 274 } 275 276 func handleKey(w *bufio.Writer, s string, pre int) error { 277 writeSpaces(w, indent*pre) 278 w.WriteString(syntaxStyle + "\"\x1b[0m" + keyStyle) 279 w.WriteString(s) 280 w.WriteString(syntaxStyle + "\":\x1b[0m ") 281 return nil 282 } 283 284 func handleNull(w *bufio.Writer, pre int) error { 285 writeSpaces(w, indent*pre) 286 w.WriteString(nullStyle + "null\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 // w.WriteString(numberStyle) 293 // w.WriteString(n.String()) 294 // w.WriteString("\x1b[0m") 295 // return nil 296 // } 297 298 func handleNumber(w *bufio.Writer, n json.Number, pre int) error { 299 writeSpaces(w, indent*pre) 300 f, _ := n.Float64() 301 if f > 0 { 302 w.WriteString(positiveNumberStyle) 303 } else if f < 0 { 304 w.WriteString(negativeNumberStyle) 305 } else { 306 w.WriteString(zeroNumberStyle) 307 } 308 w.WriteString(n.String()) 309 w.WriteString("\x1b[0m") 310 return nil 311 } 312 313 func handleObject(w *bufio.Writer, d *json.Decoder, pre, level int) error { 314 for i := 0; true; i++ { 315 t, err := d.Token() 316 if err != nil { 317 return err 318 } 319 320 if t == json.Delim('}') { 321 if i == 0 { 322 writeSpaces(w, indent*pre) 323 w.WriteString(syntaxStyle + "{}\x1b[0m") 324 } else { 325 w.WriteString("\n") 326 writeSpaces(w, indent*level) 327 w.WriteString(syntaxStyle + "}\x1b[0m") 328 } 329 return nil 330 } 331 332 if i == 0 { 333 writeSpaces(w, indent*pre) 334 w.WriteString(syntaxStyle + "{\x1b[0m\n") 335 } else { 336 // this is a good spot to check for early-quit opportunities 337 _, err = w.WriteString(syntaxStyle + ",\x1b[0m\n") 338 if err != nil { 339 return errNoMoreOutput 340 } 341 } 342 343 // the stdlib's JSON parser is supposed to complain about non-string 344 // keys anyway, but make sure just in case 345 k, ok := t.(string) 346 if !ok { 347 return errors.New(`expected key to be a string`) 348 } 349 if err := handleKey(w, k, level+1); err != nil { 350 return err 351 } 352 353 // handle value 354 t, err = d.Token() 355 if err != nil { 356 return err 357 } 358 if err := handleToken(w, d, t, 0, level+1); err != nil { 359 return err 360 } 361 } 362 363 // make the compiler happy 364 return nil 365 } 366 367 func handleString(w *bufio.Writer, s string, pre int) error { 368 writeSpaces(w, indent*pre) 369 w.WriteString(syntaxStyle + "\"\x1b[0m" + stringStyle) 370 w.WriteString(s) 371 w.WriteString(syntaxStyle + "\"\x1b[0m") 372 return nil 373 }