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