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