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