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 }