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;2;0;95;0m" 158 } 159 return "\x1b[38;2;0;135;95m" 160 } 161 if f < 0 { 162 if float64(int64(f)) == f { 163 return "\x1b[38;2;204;0;0m" 164 } 165 return "\x1b[38;2;215;95;95m" 166 } 167 if f == 0 { 168 return "\x1b[38;2;0;95;215m" 169 } 170 return "\x1b[38;2;128;128;128m" 171 } 172 173 const ( 174 columnGap = ` ` 175 tilesBackground = "\x1b[48;2;238;238;238m" 176 ) 177 178 // writeSpaces does what it says, minimizing calls to write funcs 179 func writeSpaces(w *bufio.Writer, n int) { 180 const spaces = ` ` 181 for n >= len(spaces) { 182 w.WriteString(spaces) 183 n -= len(spaces) 184 } 185 186 if n > 0 { 187 w.WriteString(spaces[:n]) 188 } 189 } 190 191 func niceTable(w *bufio.Writer, r io.Reader) error { 192 const gb = 1024 * 1024 * 1024 193 sc := bufio.NewScanner(r) 194 sc.Buffer(nil, 8*gb) 195 196 var res table 197 for sc.Scan() { 198 res.update(sc.Text()) 199 } 200 if err := sc.Err(); err != nil { 201 return err 202 } 203 204 if len(res.Rows) == 0 { 205 return nil 206 } 207 208 totalsRow := makeTotalsRow(res) 209 loopItems(totalsRow, '\t', func(i int, s string) { 210 // keep track of widest rune-counts for each column 211 w := utf8.RuneCountInString(s) 212 if res.MaxWidth[i] < w { 213 res.MaxWidth[i] = w 214 } 215 }) 216 217 for _, row := range res.Rows { 218 writeRowTiles(w, row, res) 219 writeRowItems(w, row, res) 220 w.WriteByte('\n') 221 if err := w.Flush(); err != nil { 222 // a write error may be the consequence of stdout being closed, 223 // perhaps by another app along a pipe 224 return errNoMoreOutput 225 } 226 } 227 228 writeSpaces(w, len(res.MaxWidth)) 229 writeRowItems(w, totalsRow, res) 230 w.WriteByte('\n') 231 if err := w.Flush(); err != nil { 232 // a write error may be the consequence of stdout being closed, 233 // perhaps by another app along a pipe 234 return errNoMoreOutput 235 } 236 return nil 237 } 238 239 func makeTotalsRow(res table) string { 240 var sb strings.Builder 241 for i, t := range res.Sums { 242 if i > 0 { 243 sb.WriteByte('\t') 244 } 245 if res.Numeric[i] > 0 { 246 var buf [32]byte 247 decs := res.MaxDecimals[i] 248 s := strconv.AppendFloat(buf[:0], t, 'f', decs, 64) 249 sb.Write(s) 250 } else { 251 sb.WriteString(`-`) 252 } 253 } 254 return sb.String() 255 } 256 257 // table has all summary info gathered from TSV data, along with the row 258 // themselves, stored as lines/strings 259 type table struct { 260 Rows []string 261 262 MaxWidth []int 263 264 Numeric []int 265 266 MaxDecimals []int 267 268 Sums []float64 269 } 270 271 func (t *table) update(line string) { 272 if len(line) == 0 { 273 return 274 } 275 276 t.Rows = append(t.Rows, line) 277 278 loopItems(line, '\t', func(i int, s string) { 279 // ensure column-info-slices have enough room 280 if i >= len(t.MaxWidth) { 281 t.MaxWidth = append(t.MaxWidth, 0) 282 t.Numeric = append(t.Numeric, 0) 283 t.MaxDecimals = append(t.MaxDecimals, 0) 284 t.Sums = append(t.Sums, 0.0) 285 } 286 287 // keep track of widest rune-counts for each column 288 w := utf8.RuneCountInString(s) 289 if t.MaxWidth[i] < w { 290 t.MaxWidth[i] = w 291 } 292 293 // update stats for numeric items 294 if f, ok := tryNumeric(s); ok { 295 decs := countDecimals(s) 296 if t.MaxDecimals[i] < decs { 297 t.MaxDecimals[i] = decs 298 } 299 t.Numeric[i]++ 300 t.Sums[i] += f 301 } 302 }) 303 } 304 305 func writeRowTiles(w *bufio.Writer, row string, t table) { 306 w.WriteString(tilesBackground) 307 308 end := 0 309 loopItems(row, '\t', func(i int, s string) { 310 writeTile(w, s) 311 end = i 312 }) 313 314 if end < len(t.MaxWidth)-1 { 315 w.WriteString("\x1b[0m" + tilesBackground) 316 } 317 for i := end + 1; i < len(t.MaxWidth); i++ { 318 w.WriteString("×") 319 } 320 w.WriteString("\x1b[0m") 321 } 322 323 func writeTile(w *bufio.Writer, s string) { 324 if len(s) == 0 { 325 w.WriteString("\x1b[0m" + tilesBackground + "○") 326 return 327 } 328 329 if f, ok := tryNumeric(s); ok { 330 w.WriteString(numericStyle(f)) 331 w.WriteString("■") 332 } else { 333 w.WriteString("\x1b[38;2;128;128;128m■") 334 } 335 } 336 337 func writeRowItems(w *bufio.Writer, row string, t table) { 338 loopItems(row, '\t', func(i int, s string) { 339 w.WriteString(columnGap) 340 341 if f, ok := tryNumeric(s); ok { 342 trail := 0 343 decs := countDecimals(s) 344 if decs > 0 { 345 trail = t.MaxDecimals[i] - decs 346 } else if t.MaxDecimals[i] > 0 { 347 trail = t.MaxDecimals[i] + 1 348 } 349 350 n := utf8.RuneCountInString(s) 351 lead := t.MaxWidth[i] - n - trail 352 writeSpaces(w, lead) 353 writeNumericItem(w, s, numericStyle(f)) 354 if i < len(t.MaxWidth)-1 { 355 writeSpaces(w, trail) 356 } 357 return 358 } 359 360 w.WriteString(s) 361 if i < len(t.MaxWidth)-1 { 362 n := utf8.RuneCountInString(s) 363 writeSpaces(w, t.MaxWidth[i]-n) 364 } 365 }) 366 } 367 368 // func writeNumericItem(w *bufio.Writer, s string, startStyle string) { 369 // w.WriteString(startStyle) 370 // w.WriteString(s) 371 // w.WriteString("\x1b[0m") 372 // } 373 374 func writeNumericItem(w *bufio.Writer, s string, startStyle string) { 375 w.WriteString(startStyle) 376 if len(s) > 0 && (s[0] == '-' || s[0] == '+') { 377 w.WriteByte(s[0]) 378 s = s[1:] 379 } 380 381 dot := strings.IndexByte(s, '.') 382 if dot < 0 { 383 restyleDigits(w, s, altDigitStyle) 384 w.WriteString("\x1b[0m") 385 return 386 } 387 388 if len(s[:dot]) > 3 { 389 restyleDigits(w, s[:dot], altDigitStyle) 390 w.WriteString("\x1b[0m") 391 w.WriteString(startStyle) 392 w.WriteByte('.') 393 } else { 394 w.WriteString(s[:dot]) 395 w.WriteByte('.') 396 } 397 398 rest := s[dot+1:] 399 restyleDigits(w, rest, altDigitStyle) 400 if len(rest) < 4 { 401 w.WriteString("\x1b[0m") 402 } 403 } 404 405 // restyleDigits renders a run of digits as alternating styled/unstyled runs 406 // of 3 digits, which greatly improves readability, and is the only purpose 407 // of this app; string is assumed to be all decimal digits 408 func restyleDigits(w *bufio.Writer, digits string, altStyle string) { 409 if len(digits) < 4 { 410 // digit sequence is short, so emit it as is 411 w.WriteString(digits) 412 return 413 } 414 415 // separate leading 0..2 digits which don't align with the 3-digit groups 416 i := len(digits) % 3 417 // emit leading digits unstyled, if there are any 418 w.WriteString(digits[:i]) 419 // the rest is guaranteed to have a length which is a multiple of 3 420 digits = digits[i:] 421 422 // start by styling, unless there were no leading digits 423 style := i != 0 424 425 for len(digits) > 0 { 426 if style { 427 w.WriteString(altStyle) 428 w.WriteString(digits[:3]) 429 w.WriteString("\x1b[0m") 430 } else { 431 w.WriteString(digits[:3]) 432 } 433 434 // advance to the next triple: the start of this func is supposed 435 // to guarantee this step always works 436 digits = digits[3:] 437 438 // alternate between styled and unstyled 3-digit groups 439 style = !style 440 } 441 }