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