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 }