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 }