File: nt.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 nt: this version has no http(s) support. Even 27 the unit-tests from the original nt 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 nt.go 33 */ 34 35 package main 36 37 import ( 38 "bufio" 39 "errors" 40 "io" 41 "math" 42 "os" 43 "strconv" 44 "strings" 45 "unicode/utf8" 46 ) 47 48 const info = ` 49 nt [options...] [filenames...] 50 51 52 Nice Tables realigns and styles TSV (tab-separated values) data using ANSI 53 sequences. When not given filepaths to read data from, this tool reads from 54 standard input. 55 56 If you're using Linux or MacOS, you may find this cmd-line shortcut useful: 57 58 # View Nice Table(s) / Very Nice Table(s); uses my tools "nt" and "nn" 59 vnt() { 60 awk 1 "$@" | nl -b a -w 1 -v 0 | nt | nn | 61 awk '(NR - 1) % 5 == 1 && NR > 1 { print "" } 1' | less -JMKiCRS 62 } 63 ` 64 65 const altDigitStyle = "\x1b[38;2;168;168;168m" 66 67 func main() { 68 if len(os.Args) > 1 { 69 switch os.Args[1] { 70 case `-h`, `--h`, `-help`, `--help`: 71 os.Stderr.WriteString(info[1:]) 72 return 73 } 74 } 75 76 if err := run(os.Args[1:]); err != nil { 77 os.Stderr.WriteString("\x1b[31m") 78 os.Stderr.WriteString(err.Error()) 79 os.Stderr.WriteString("\x1b[0m\n") 80 os.Exit(1) 81 } 82 } 83 84 func run(paths []string) error { 85 bw := bufio.NewWriter(os.Stdout) 86 defer bw.Flush() 87 88 for _, p := range paths { 89 if err := handleFile(bw, p); err != nil { 90 return err 91 } 92 } 93 94 if len(paths) == 0 { 95 return niceTable(bw, os.Stdin) 96 } 97 return nil 98 } 99 100 func handleFile(w *bufio.Writer, path string) error { 101 f, err := os.Open(path) 102 if err != nil { 103 // on windows, file-not-found error messages may mention `CreateFile`, 104 // even when trying to open files in read-only mode 105 return errors.New(`can't open file named ` + path) 106 } 107 defer f.Close() 108 return niceTable(w, f) 109 } 110 111 // loopItems lets you loop over a line's items, allocation-free style; 112 // when given empty strings, the callback func is never called 113 func loopItems(s string, sep byte, f func(i int, s string)) { 114 if len(s) == 0 { 115 return 116 } 117 118 for i := 0; true; i++ { 119 if j := strings.IndexByte(s, sep); j >= 0 { 120 f(i, s[:j]) 121 s = s[j+1:] 122 continue 123 } 124 125 f(i, s) 126 return 127 } 128 } 129 130 // tryNumeric tries to parse a strings as a valid/useable float64, which 131 // excludes NaNs and the infinities, returning a boolean instead of an 132 // error 133 func tryNumeric(s string) (f float64, ok bool) { 134 f, err := strconv.ParseFloat(s, 64) 135 if err == nil && !math.IsNaN(f) && !math.IsInf(f, 0) { 136 return f, true 137 } 138 return f, false 139 } 140 141 // countDecimals counts decimal digits from the string given, assuming it 142 // represents a valid/useable float64, when parsed 143 func countDecimals(s string) int { 144 if dot := strings.IndexByte(s, '.'); dot >= 0 { 145 return len(s) - dot - 1 146 } 147 return 0 148 } 149 150 func numericStyle(f float64) string { 151 if f > 0 { 152 if float64(int64(f)) == f { 153 return "\x1b[38;5;29m" 154 } 155 return "\x1b[38;5;22m" 156 } 157 if f < 0 { 158 if float64(int64(f)) == f { 159 return "\x1b[38;5;1m" 160 } 161 return "\x1b[38;5;167m" 162 } 163 if f == 0 { 164 // return "\x1b[38;5;33m" 165 return "\x1b[38;5;26m" 166 } 167 return "\x1b[38;5;244m" 168 } 169 170 const ( 171 columnGap = ` ` 172 tilesBackground = "\x1b[48;5;255m" 173 ) 174 175 // writeSpaces does what it says, minimizing calls to write funcs 176 func writeSpaces(w *bufio.Writer, n int) { 177 const spaces = ` ` 178 for n >= len(spaces) { 179 w.WriteString(spaces) 180 n -= len(spaces) 181 } 182 183 if n > 0 { 184 w.WriteString(spaces[:n]) 185 } 186 } 187 188 func niceTable(w *bufio.Writer, r io.Reader) error { 189 const gb = 1024 * 1024 * 1024 190 sc := bufio.NewScanner(r) 191 sc.Buffer(nil, 8*gb) 192 193 var res table 194 for sc.Scan() { 195 res.update(sc.Text()) 196 } 197 if err := sc.Err(); err != nil { 198 return err 199 } 200 201 if len(res.Rows) == 0 { 202 return nil 203 } 204 205 totalsRow := makeTotalsRow(res) 206 loopItems(totalsRow, '\t', func(i int, s string) { 207 // keep track of widest rune-counts for each column 208 w := utf8.RuneCountInString(s) 209 if res.MaxWidth[i] < w { 210 res.MaxWidth[i] = w 211 } 212 }) 213 214 for _, row := range res.Rows { 215 writeRowTiles(w, row, res) 216 writeRowItems(w, row, res) 217 if err := w.WriteByte('\n'); err != nil { 218 return nil 219 } 220 } 221 222 writeSpaces(w, len(res.MaxWidth)) 223 writeRowItems(w, totalsRow, res) 224 w.WriteByte('\n') 225 return nil 226 } 227 228 func makeTotalsRow(res table) string { 229 var sb strings.Builder 230 for i, t := range res.Sums { 231 if i > 0 { 232 sb.WriteByte('\t') 233 } 234 if res.Numeric[i] > 0 { 235 var buf [32]byte 236 decs := res.MaxDecimals[i] 237 s := strconv.AppendFloat(buf[:0], t, 'f', decs, 64) 238 sb.Write(s) 239 } else { 240 sb.WriteString(`-`) 241 } 242 } 243 return sb.String() 244 } 245 246 // table has all summary info gathered from TSV data, along with the row 247 // themselves, stored as lines/strings 248 type table struct { 249 Rows []string 250 251 MaxWidth []int 252 253 Numeric []int 254 255 MaxDecimals []int 256 257 Sums []float64 258 } 259 260 func (t *table) update(line string) { 261 if len(line) == 0 { 262 return 263 } 264 265 t.Rows = append(t.Rows, line) 266 267 loopItems(line, '\t', func(i int, s string) { 268 // ensure column-info-slices have enough room 269 if i >= len(t.MaxWidth) { 270 t.MaxWidth = append(t.MaxWidth, 0) 271 t.Numeric = append(t.Numeric, 0) 272 t.MaxDecimals = append(t.MaxDecimals, 0) 273 t.Sums = append(t.Sums, 0.0) 274 } 275 276 // keep track of widest rune-counts for each column 277 w := utf8.RuneCountInString(s) 278 if t.MaxWidth[i] < w { 279 t.MaxWidth[i] = w 280 } 281 282 // update stats for numeric items 283 if f, ok := tryNumeric(s); ok { 284 decs := countDecimals(s) 285 if t.MaxDecimals[i] < decs { 286 t.MaxDecimals[i] = decs 287 } 288 t.Numeric[i]++ 289 t.Sums[i] += f 290 } 291 }) 292 } 293 294 func writeRowTiles(w *bufio.Writer, row string, t table) { 295 w.WriteString(tilesBackground) 296 297 end := 0 298 loopItems(row, '\t', func(i int, s string) { 299 writeTile(w, s) 300 end = i 301 }) 302 303 if end < len(t.MaxWidth)-1 { 304 w.WriteString("\x1b[0m" + tilesBackground) 305 } 306 for i := end + 1; i < len(t.MaxWidth); i++ { 307 w.WriteString("×") 308 } 309 w.WriteString("\x1b[0m") 310 } 311 312 func writeTile(w *bufio.Writer, s string) { 313 if len(s) == 0 { 314 w.WriteString("\x1b[0m" + tilesBackground + "○") 315 return 316 } 317 318 if f, ok := tryNumeric(s); ok { 319 w.WriteString(numericStyle(f)) 320 w.WriteString("■") 321 } else { 322 w.WriteString("\x1b[38;5;244m■") 323 } 324 } 325 326 func writeRowItems(w *bufio.Writer, row string, t table) { 327 loopItems(row, '\t', func(i int, s string) { 328 w.WriteString(columnGap) 329 330 if f, ok := tryNumeric(s); ok { 331 trail := 0 332 decs := countDecimals(s) 333 if decs > 0 { 334 trail = t.MaxDecimals[i] - decs 335 } else if t.MaxDecimals[i] > 0 { 336 trail = t.MaxDecimals[i] + 1 337 } 338 339 n := utf8.RuneCountInString(s) 340 lead := t.MaxWidth[i] - n - trail 341 writeSpaces(w, lead) 342 writeNumericItem(w, s, numericStyle(f)) 343 if i < len(t.MaxWidth)-1 { 344 writeSpaces(w, trail) 345 } 346 return 347 } 348 349 w.WriteString(s) 350 if i < len(t.MaxWidth)-1 { 351 n := utf8.RuneCountInString(s) 352 writeSpaces(w, t.MaxWidth[i]-n) 353 } 354 }) 355 } 356 357 // func writeNumericItem(w *bufio.Writer, s string, startStyle string) { 358 // w.WriteString(startStyle) 359 // w.WriteString(s) 360 // w.WriteString("\x1b[0m") 361 // } 362 363 func writeNumericItem(w *bufio.Writer, s string, startStyle string) { 364 w.WriteString(startStyle) 365 if len(s) > 0 && (s[0] == '-' || s[0] == '+') { 366 w.WriteByte(s[0]) 367 s = s[1:] 368 } 369 370 dot := strings.IndexByte(s, '.') 371 if dot < 0 { 372 restyleDigits(w, s, altDigitStyle) 373 w.WriteString("\x1b[0m") 374 return 375 } 376 377 if len(s[:dot]) > 3 { 378 restyleDigits(w, s[:dot], altDigitStyle) 379 w.WriteString("\x1b[0m") 380 w.WriteString(startStyle) 381 w.WriteByte('.') 382 } else { 383 w.WriteString(s[:dot]) 384 w.WriteByte('.') 385 } 386 387 rest := s[dot+1:] 388 restyleDigits(w, rest, altDigitStyle) 389 if len(rest) < 4 { 390 w.WriteString("\x1b[0m") 391 } 392 } 393 394 // restyleDigits renders a run of digits as alternating styled/unstyled runs 395 // of 3 digits, which greatly improves readability, and is the only purpose 396 // of this app; string is assumed to be all decimal digits 397 func restyleDigits(w *bufio.Writer, digits string, altStyle string) { 398 if len(digits) < 4 { 399 // digit sequence is short, so emit it as is 400 w.WriteString(digits) 401 return 402 } 403 404 // separate leading 0..2 digits which don't align with the 3-digit groups 405 i := len(digits) % 3 406 // emit leading digits unstyled, if there are any 407 w.WriteString(digits[:i]) 408 // the rest is guaranteed to have a length which is a multiple of 3 409 digits = digits[i:] 410 411 // start by styling, unless there were no leading digits 412 style := i != 0 413 414 for len(digits) > 0 { 415 if style { 416 w.WriteString(altStyle) 417 w.WriteString(digits[:3]) 418 w.WriteString("\x1b[0m") 419 } else { 420 w.WriteString(digits[:3]) 421 } 422 423 // advance to the next triple: the start of this func is supposed 424 // to guarantee this step always works 425 digits = digits[3:] 426 427 // alternate between styled and unstyled 3-digit groups 428 style = !style 429 } 430 }