File: ncol.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright © 2026 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 To compile a smaller-sized command-line app, you can use the `go` command as
  27 follows:
  28 
  29 go build -ldflags "-s -w" -trimpath ncol.go
  30 */
  31 
  32 package main
  33 
  34 import (
  35     "bufio"
  36     "errors"
  37     "io"
  38     "os"
  39     "strconv"
  40     "strings"
  41     "unicode/utf8"
  42 )
  43 
  44 const info = `
  45 ncol [options...] [filenames...]
  46 
  47 Nice COLumns realigns and styles data tables using ANSI color sequences. In
  48 particular, all auto-detected numbers are styled so they're easier to read
  49 at a glance. Input tables can be either lines of space-separated values or
  50 tab-separated values, and are auto-detected using the first non-empty line.
  51 
  52 When not given filepaths to read data from, this tool reads from standard
  53 input by default.
  54 
  55 The options are, available both in single and double-dash versions
  56 
  57     -h      show this help message
  58     -help   show this help message
  59 
  60     -no-sums      avoid showing a final row with column sums
  61     -unsummed     avoid showing a final row with column sums
  62 
  63     -no-tiles     avoid showing color-coded tiles at the start of lines
  64     -untiled      avoid showing color-coded tiles at the start of lines
  65 `
  66 
  67 const columnGap = 2
  68 
  69 // altDigitStyle is used to make 4+ digit-runs easier to read
  70 const altDigitStyle = "\x1b[38;2;168;168;168m"
  71 
  72 func main() {
  73     sums := true
  74     tiles := true
  75     args := os.Args[1:]
  76 
  77     if len(args) > 0 {
  78         switch args[0] {
  79         case `-h`, `--h`, `-help`, `--help`:
  80             os.Stdout.WriteString(info[1:])
  81             return
  82         }
  83     }
  84 
  85     for len(args) > 0 {
  86         switch args[0] {
  87         case
  88             `-no-sums`, `--no-sums`, `-no-totals`, `--no-totals`,
  89             `-unsummed`, `--unsummed`, `-untotaled`, `--untotaled`,
  90             `-untotalled`, `--untotalled`:
  91             sums = false
  92             args = args[1:]
  93             continue
  94 
  95         case `-no-tiles`, `--no-tiles`, `-untiled`, `--untiled`:
  96             tiles = false
  97             args = args[1:]
  98             continue
  99 
 100         case `--`:
 101             args = args[1:]
 102         }
 103 
 104         break
 105     }
 106 
 107     var res table
 108     res.ShowTiles = tiles
 109     res.ShowSums = sums
 110 
 111     if err := run(args, &res); err != nil {
 112         os.Stderr.WriteString(err.Error())
 113         os.Stderr.WriteString("\n")
 114         os.Exit(1)
 115     }
 116 }
 117 
 118 // table has all summary info gathered from the data, along with the row
 119 // themselves, stored as lines/strings
 120 type table struct {
 121     Columns int
 122 
 123     Rows []string
 124 
 125     MaxWidth []int
 126 
 127     MaxDotDecimals []int
 128 
 129     Numeric []int
 130 
 131     Sums []float64
 132 
 133     LoopItems func(line string, items int, t *table, f itemFunc) int
 134 
 135     sb strings.Builder
 136 
 137     ShowTiles bool
 138 
 139     ShowSums bool
 140 }
 141 
 142 type itemFunc func(i int, s string, t *table)
 143 
 144 func run(paths []string, res *table) error {
 145     for _, p := range paths {
 146         if err := handleFile(res, p); err != nil {
 147             return err
 148         }
 149     }
 150 
 151     if len(paths) == 0 {
 152         if err := handleReader(res, os.Stdin); err != nil {
 153             return err
 154         }
 155     }
 156 
 157     bw := bufio.NewWriterSize(os.Stdout, 32*1024)
 158     defer bw.Flush()
 159     realign(bw, res)
 160     return nil
 161 }
 162 
 163 func handleFile(res *table, path string) error {
 164     f, err := os.Open(path)
 165     if err != nil {
 166         // on windows, file-not-found error messages may mention `CreateFile`,
 167         // even when trying to open files in read-only mode
 168         return errors.New(`can't open file named ` + path)
 169     }
 170     defer f.Close()
 171     return handleReader(res, f)
 172 }
 173 
 174 func handleReader(t *table, r io.Reader) error {
 175     const gb = 1024 * 1024 * 1024
 176     sc := bufio.NewScanner(r)
 177     sc.Buffer(nil, 8*gb)
 178 
 179     for sc.Scan() {
 180         line := sc.Text()
 181         if len(line) == 0 {
 182             continue
 183         }
 184 
 185         t.Rows = append(t.Rows, line)
 186 
 187         if t.Columns == 0 {
 188             if t.LoopItems == nil {
 189                 if strings.IndexByte(line, '\t') >= 0 {
 190                     t.LoopItems = loopItemsTSV
 191                 } else {
 192                     t.LoopItems = loopItemsSSV
 193                 }
 194             }
 195 
 196             const maxInt = int(^uint(0) >> 1)
 197             t.Columns = t.LoopItems(line, maxInt, t, doNothing)
 198         }
 199 
 200         t.LoopItems(line, t.Columns, t, updateItem)
 201     }
 202 
 203     return sc.Err()
 204 }
 205 
 206 // doNothing is given to LoopItems to count items, while doing nothing else
 207 func doNothing(i int, s string, t *table) {
 208 }
 209 
 210 func updateItem(i int, s string, t *table) {
 211     // ensure column-info-slices have enough room
 212     if i >= len(t.MaxWidth) {
 213         t.MaxWidth = append(t.MaxWidth, 0)
 214         t.MaxDotDecimals = append(t.MaxDotDecimals, 0)
 215         t.Numeric = append(t.Numeric, 0)
 216         t.Sums = append(t.Sums, 0)
 217     }
 218 
 219     // keep track of widest rune-counts for each column
 220     w := countWidth(s)
 221     if t.MaxWidth[i] < w {
 222         t.MaxWidth[i] = w
 223     }
 224 
 225     // update stats for numeric items
 226     if isNumeric(s, &(t.sb)) {
 227         dd := countDotDecimals(s)
 228         if t.MaxDotDecimals[i] < dd {
 229             t.MaxDotDecimals[i] = dd
 230         }
 231 
 232         t.Numeric[i]++
 233         f, _ := strconv.ParseFloat(t.sb.String(), 64)
 234         t.Sums[i] += f
 235     }
 236 }
 237 
 238 // loopItemsSSV loops over a line's items, allocation-free style; when given
 239 // empty strings, the callback func is never called
 240 func loopItemsSSV(s string, max int, t *table, f itemFunc) int {
 241     i := 0
 242     s = trimTrailingSpaces(s)
 243 
 244     for {
 245         s = trimLeadingSpaces(s)
 246         if len(s) == 0 {
 247             return i
 248         }
 249 
 250         if i+1 == max {
 251             f(i, s, t)
 252             return i + 1
 253         }
 254 
 255         j := strings.IndexByte(s, ' ')
 256         if j < 0 {
 257             f(i, s, t)
 258             return i + 1
 259         }
 260 
 261         f(i, s[:j], t)
 262         s = s[j+1:]
 263         i++
 264     }
 265 }
 266 
 267 func trimLeadingSpaces(s string) string {
 268     for len(s) > 0 && s[0] == ' ' {
 269         s = s[1:]
 270     }
 271     return s
 272 }
 273 
 274 func trimTrailingSpaces(s string) string {
 275     for len(s) > 0 && s[len(s)-1] == ' ' {
 276         s = s[:len(s)-1]
 277     }
 278     return s
 279 }
 280 
 281 // loopItemsTSV loops over a line's tab-separated items, allocation-free style;
 282 // when given empty strings, the callback func is never called
 283 func loopItemsTSV(s string, max int, t *table, f itemFunc) int {
 284     if len(s) == 0 {
 285         return 0
 286     }
 287 
 288     i := 0
 289 
 290     for {
 291         if i+1 == max {
 292             f(i, s, t)
 293             return i + 1
 294         }
 295 
 296         j := strings.IndexByte(s, '\t')
 297         if j < 0 {
 298             f(i, s, t)
 299             return i + 1
 300         }
 301 
 302         f(i, s[:j], t)
 303         s = s[j+1:]
 304         i++
 305     }
 306 }
 307 
 308 func skipLeadingEscapeSequences(s string) string {
 309     for len(s) >= 2 {
 310         if s[0] != '\x1b' {
 311             return s
 312         }
 313 
 314         switch s[1] {
 315         case '[':
 316             s = skipSingleLeadingANSI(s[2:])
 317 
 318         case ']':
 319             if len(s) < 3 || s[2] != '8' {
 320                 return s
 321             }
 322             s = skipSingleLeadingOSC(s[3:])
 323 
 324         default:
 325             return s
 326         }
 327     }
 328 
 329     return s
 330 }
 331 
 332 func skipSingleLeadingANSI(s string) string {
 333     for len(s) > 0 {
 334         upper := s[0] &^ 32
 335         s = s[1:]
 336         if 'A' <= upper && upper <= 'Z' {
 337             break
 338         }
 339     }
 340 
 341     return s
 342 }
 343 
 344 func skipSingleLeadingOSC(s string) string {
 345     var prev byte
 346 
 347     for len(s) > 0 {
 348         b := s[0]
 349         s = s[1:]
 350         if prev == '\x1b' && b == '\\' {
 351             break
 352         }
 353         prev = b
 354     }
 355 
 356     return s
 357 }
 358 
 359 // isNumeric checks if a string is valid/useable as a number
 360 func isNumeric(s string, sb *strings.Builder) bool {
 361     if len(s) == 0 {
 362         return false
 363     }
 364 
 365     sb.Reset()
 366 
 367     s = skipLeadingEscapeSequences(s)
 368     if len(s) > 0 && (s[0] == '+' || s[0] == '-') {
 369         sb.WriteByte(s[0])
 370         s = s[1:]
 371     }
 372 
 373     s = skipLeadingEscapeSequences(s)
 374     if len(s) == 0 {
 375         return false
 376     }
 377     if b := s[0]; b == '.' {
 378         sb.WriteByte(b)
 379         return isDigits(s[1:], sb)
 380     }
 381 
 382     digits := 0
 383 
 384     for {
 385         s = skipLeadingEscapeSequences(s)
 386         if len(s) == 0 {
 387             break
 388         }
 389 
 390         b := s[0]
 391         sb.WriteByte(b)
 392 
 393         if b == '.' {
 394             return isDigits(s[1:], sb)
 395         }
 396 
 397         if !('0' <= b && b <= '9') {
 398             return false
 399         }
 400 
 401         digits++
 402         s = s[1:]
 403     }
 404 
 405     s = skipLeadingEscapeSequences(s)
 406     return len(s) == 0 && digits > 0
 407 }
 408 
 409 func isDigits(s string, sb *strings.Builder) bool {
 410     if len(s) == 0 {
 411         return false
 412     }
 413 
 414     digits := 0
 415 
 416     for {
 417         s = skipLeadingEscapeSequences(s)
 418         if len(s) == 0 {
 419             break
 420         }
 421 
 422         if b := s[0]; '0' <= b && b <= '9' {
 423             sb.WriteByte(b)
 424             s = s[1:]
 425             digits++
 426         } else {
 427             return false
 428         }
 429     }
 430 
 431     s = skipLeadingEscapeSequences(s)
 432     return len(s) == 0 && digits > 0
 433 }
 434 
 435 // countDecimals counts decimal digits from the string given, assuming it
 436 // represents a valid/useable float64, when parsed
 437 func countDecimals(s string) int {
 438     dot := strings.IndexByte(s, '.')
 439     if dot < 0 {
 440         return 0
 441     }
 442 
 443     decs := 0
 444     s = s[dot+1:]
 445 
 446     for len(s) > 0 {
 447         s = skipLeadingEscapeSequences(s)
 448         if len(s) == 0 {
 449             break
 450         }
 451         if '0' <= s[0] && s[0] <= '9' {
 452             decs++
 453         }
 454         s = s[1:]
 455     }
 456 
 457     return decs
 458 }
 459 
 460 // countDotDecimals is like func countDecimals, but this one also includes
 461 // the dot, when any decimals are present, else the count stays at 0
 462 func countDotDecimals(s string) int {
 463     decs := countDecimals(s)
 464     if decs > 0 {
 465         return decs + 1
 466     }
 467     return decs
 468 }
 469 
 470 func countWidth(s string) int {
 471     width := 0
 472 
 473     for len(s) > 0 {
 474         i := indexStartANSI(s)
 475         if i < 0 {
 476             width += utf8.RuneCountInString(s)
 477             return width
 478         }
 479 
 480         width += utf8.RuneCountInString(s[:i])
 481 
 482         for len(s) > 0 {
 483             upper := s[0] &^ 32
 484             s = s[1:]
 485             if 'A' <= upper && upper <= 'Z' {
 486                 break
 487             }
 488         }
 489     }
 490 
 491     return width
 492 }
 493 
 494 func indexStartANSI(s string) int {
 495     var prev byte
 496 
 497     for i := range s {
 498         b := s[i]
 499         if prev == '\x1b' && b == '[' {
 500             return i - 1
 501         }
 502         prev = b
 503     }
 504 
 505     return -1
 506 }
 507 
 508 func realign(w *bufio.Writer, t *table) {
 509     // make sums row first, as final alignments are usually affected by these
 510     var sums []string
 511     if t.ShowSums {
 512         sums = make([]string, 0, t.Columns)
 513     }
 514 
 515     for i := 0; i < t.Columns && t.ShowSums; i++ {
 516         s := `-`
 517         width := 1
 518 
 519         if t.Numeric[i] > 0 {
 520             decs := t.MaxDotDecimals[i]
 521             if decs > 0 {
 522                 decs--
 523             }
 524 
 525             var buf [64]byte
 526             s = string(strconv.AppendFloat(buf[:0], t.Sums[i], 'f', decs, 64))
 527             width = len(s)
 528         }
 529 
 530         if t.MaxWidth[i] < width {
 531             t.MaxWidth[i] = width
 532         }
 533         sums = append(sums, s)
 534     }
 535 
 536     due := 0
 537     showItem := func(i int, s string, t *table) {
 538         if i > 0 {
 539             due += columnGap
 540         }
 541 
 542         if isNumeric(s, &(t.sb)) {
 543             dd := countDotDecimals(s)
 544             rpad := t.MaxDotDecimals[i] - dd
 545             width := countWidth(s)
 546             lpad := t.MaxWidth[i] - (width + rpad) + due
 547             writeSpaces(w, lpad)
 548             f, _ := strconv.ParseFloat(t.sb.String(), 64)
 549             writeNumericItem(w, s, numericStyle(f))
 550             due = rpad
 551             return
 552         }
 553 
 554         writeSpaces(w, due)
 555         w.WriteString(s)
 556         due = t.MaxWidth[i] - countWidth(s)
 557     }
 558 
 559     writeTile := func(i int, s string, t *table) {
 560         if len(s) == 0 {
 561             w.WriteString("\x1b[0m○")
 562             return
 563         }
 564 
 565         if isNumeric(s, &(t.sb)) {
 566             f, _ := strconv.ParseFloat(t.sb.String(), 64)
 567             w.WriteString(numericStyle(f))
 568             w.WriteString("")
 569             return
 570         }
 571 
 572         if s[0] == ' ' || s[len(s)-1] == ' ' {
 573             w.WriteString("\x1b[38;2;196;160;0m■")
 574             return
 575         }
 576 
 577         w.WriteString("\x1b[38;2;128;128;128m■")
 578     }
 579 
 580     // show realigned rows
 581 
 582     for _, line := range t.Rows {
 583         due = 0
 584         if t.ShowTiles {
 585             end := t.LoopItems(line, t.Columns, t, writeTile)
 586             if end < len(t.MaxWidth)-1 {
 587                 w.WriteString("\x1b[0m")
 588             }
 589             for i := end + 1; i < len(t.MaxWidth); i++ {
 590                 w.WriteString("×")
 591             }
 592             w.WriteString("\x1b[0m")
 593             due += columnGap
 594         }
 595 
 596         t.LoopItems(line, t.Columns, t, showItem)
 597         if w.WriteByte('\n') != nil {
 598             return
 599         }
 600     }
 601 
 602     if t.Columns > 0 && t.ShowSums {
 603         realignSums(w, t, sums)
 604     }
 605 }
 606 
 607 func realignSums(w *bufio.Writer, t *table, sums []string) {
 608     due := 0
 609     if t.ShowTiles {
 610         due += t.Columns + columnGap
 611     }
 612 
 613     for i, s := range sums {
 614         if i > 0 {
 615             due += columnGap
 616         }
 617 
 618         if t.Numeric[i] == 0 {
 619             writeSpaces(w, due)
 620             w.WriteString(s)
 621             due = t.MaxWidth[i] - countWidth(s)
 622             continue
 623         }
 624 
 625         lpad := t.MaxWidth[i] - len(s) + due
 626         writeSpaces(w, lpad)
 627         writeNumericItem(w, s, numericStyle(t.Sums[i]))
 628         due = 0
 629     }
 630 
 631     w.WriteByte('\n')
 632 }
 633 
 634 // writeSpaces does what it says, minimizing calls to write-like funcs
 635 func writeSpaces(w *bufio.Writer, n int) {
 636     const spaces = `                                `
 637     if n < 1 {
 638         return
 639     }
 640 
 641     for n >= len(spaces) {
 642         w.WriteString(spaces)
 643         n -= len(spaces)
 644     }
 645     w.WriteString(spaces[:n])
 646 }
 647 
 648 func writeRowTiles(w *bufio.Writer, s string, t *table, writeTile itemFunc) {
 649     end := t.LoopItems(s, t.Columns, t, writeTile)
 650 
 651     if end < len(t.MaxWidth)-1 {
 652         w.WriteString("\x1b[0m")
 653     }
 654     for i := end + 1; i < len(t.MaxWidth); i++ {
 655         w.WriteString("×")
 656     }
 657     w.WriteString("\x1b[0m")
 658 }
 659 
 660 func numericStyle(f float64) string {
 661     if f > 0 {
 662         if float64(int64(f)) == f {
 663             return "\x1b[38;2;0;135;0m"
 664         }
 665         return "\x1b[38;2;0;155;95m"
 666     }
 667     if f < 0 {
 668         if float64(int64(f)) == f {
 669             return "\x1b[38;2;204;0;0m"
 670         }
 671         return "\x1b[38;2;215;95;95m"
 672     }
 673     if f == 0 {
 674         return "\x1b[38;2;0;95;215m"
 675     }
 676     return "\x1b[38;2;128;128;128m"
 677 }
 678 
 679 func writeNumericItem(w *bufio.Writer, s string, startStyle string) {
 680     w.WriteString(startStyle)
 681     if len(s) > 0 && (s[0] == '-' || s[0] == '+') {
 682         w.WriteByte(s[0])
 683         s = s[1:]
 684     }
 685 
 686     dot := strings.IndexByte(s, '.')
 687     if dot < 0 {
 688         restyleDigits(w, s, altDigitStyle)
 689         w.WriteString("\x1b[0m")
 690         return
 691     }
 692 
 693     if len(s[:dot]) > 3 {
 694         restyleDigits(w, s[:dot], altDigitStyle)
 695         w.WriteString("\x1b[0m")
 696         w.WriteString(startStyle)
 697         w.WriteByte('.')
 698     } else {
 699         w.WriteString(s[:dot])
 700         w.WriteByte('.')
 701     }
 702 
 703     rest := s[dot+1:]
 704     restyleDigits(w, rest, altDigitStyle)
 705     if len(rest) < 4 {
 706         w.WriteString("\x1b[0m")
 707     }
 708 }
 709 
 710 // restyleDigits renders a run of digits as alternating styled/unstyled runs
 711 // of 3 digits, which greatly improves readability, and is the only purpose
 712 // of this app; string is assumed to be all decimal digits
 713 func restyleDigits(w *bufio.Writer, digits string, altStyle string) {
 714     if len(digits) < 4 {
 715         // digit sequence is short, so emit it as is
 716         w.WriteString(digits)
 717         return
 718     }
 719 
 720     // separate leading 0..2 digits which don't align with the 3-digit groups
 721     i := len(digits) % 3
 722     // emit leading digits unstyled, if there are any
 723     w.WriteString(digits[:i])
 724     // the rest is guaranteed to have a length which is a multiple of 3
 725     digits = digits[i:]
 726 
 727     // start by styling, unless there were no leading digits
 728     style := i != 0
 729 
 730     for len(digits) > 0 {
 731         if style {
 732             w.WriteString(altStyle)
 733             w.WriteString(digits[:3])
 734             w.WriteString("\x1b[0m")
 735         } else {
 736             w.WriteString(digits[:3])
 737         }
 738 
 739         // advance to the next triple: the start of this func is supposed
 740         // to guarantee this step always works
 741         digits = digits[3:]
 742 
 743         // alternate between styled and unstyled 3-digit groups
 744         style = !style
 745     }
 746 }