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