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 }