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