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 }