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