File: ncol.sh
   1 #!/bin/sh
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright (c) 2026 pacman64
   6 #
   7 # Permission is hereby granted, free of charge, to any person obtaining a copy
   8 # of this software and associated documentation files (the "Software"), to deal
   9 # in the Software without restriction, including without limitation the rights
  10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11 # copies of the Software, and to permit persons to whom the Software is
  12 # furnished to do so, subject to the following conditions:
  13 #
  14 # The above copyright notice and this permission notice shall be included in
  15 # all copies or substantial portions of the Software.
  16 #
  17 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  23 # SOFTWARE.
  24 
  25 
  26 # ncol [options...] [files...]
  27 #
  28 # Nice COLumns realigns and styles data tables using ANSI color sequences. In
  29 # particular, all auto-detected numbers are styled so they're easier to read
  30 # at a glance. Input tables can be either lines of space-separated values or
  31 # tab-separated values, and are auto-detected using the first non-empty line.
  32 #
  33 # When not given filepaths to read data from, this tool reads from standard
  34 # input by default.
  35 #
  36 # For positive numbers, a colorblind-friendly blue is used instead of green
  37 # if either environment variable COLORBLIND or COLOR_BLIND is declared and set
  38 # to 1.
  39 #
  40 # The options are, available both in single and double-dash versions
  41 #
  42 #    -h, -help    show this help message
  43 #    -m, -most    use the row with the most items for the item-count
  44 #    -t, -tsv     force TSV-input mode, no matter the first line
  45 
  46 
  47 sep=' '
  48 most=0
  49 
  50 while [ $# -gt 0 ]; do
  51     case "$1" in
  52         -h|--h|-help|--help)
  53             awk '/^# +ncol /, /^$/ { gsub(/^# ?/, ""); print }' "$0"
  54             exit 0
  55         ;;
  56 
  57         -m|--m|-most|--most) most=1; shift; continue ;;
  58 
  59         -t|--t|-tsv|--tsv) sep="\t"; shift; continue ;;
  60 
  61         -) break ;;
  62 
  63         -) break ;;
  64 
  65         --) shift; break ;;
  66 
  67         -*)
  68             printf "unsupported option '%s'\n" "$1" >&2
  69             exit 1
  70         ;;
  71     esac
  72 
  73     break
  74 done
  75 
  76 # show all non-existing files given
  77 failed=0
  78 for arg in "$@"; do
  79     [ "${arg}" = "-" ] && continue
  80     [ -e "${arg}" ] && continue
  81     printf "no file named \"%s\"\n" "${arg}" >&2
  82     failed=1
  83 done
  84 
  85 [ "${failed}" -gt 0 ] && exit 2
  86 
  87 awk -F "${sep}" -v most="${most}" '
  88 BEGIN {
  89     if (SUBSEP == "") SUBSEP = "\034"
  90 
  91     # normal positive-style is green, colorblind-friendly positive-style
  92     # becomes the same blue as the zero-style
  93     cb = ENVIRON["COLORBLIND"] != 0 || ENVIRON["COLOR_BLIND"] != 0
  94 
  95     pdtile = cb ? "\x1b[38;2;0;95;215m■" : "\x1b[38;2;0;155;95m■"
  96     pitile = cb ? "\x1b[38;2;0;75;235m■" : "\x1b[38;2;0;135;0m■"
  97     pdrgb = cb ? "0;95;215" : "0;135;95"
  98     pirgb = cb ? "0;75;235" : "0;155;0"
  99 }
 100 
 101 # always ignore trailing carriage-returns
 102 { gsub(/\r$/, "") }
 103 
 104 # first non-empty line auto-detects SSV vs. TSV, and the column-count
 105 ncols == 0 { ncols = NF; if (/\t/) { FS = "\t"; $0 = $0 } }
 106 
 107 ncols > 0 {
 108     if (most && ncols < NF) ncols = NF
 109     nitems[++nrows] = NF
 110 
 111     for (i = 1; i <= NF; i++) {
 112         data[nrows SUBSEP i] = $i
 113 
 114         plain = $i
 115         gsub(/\x1b\[[0-9;]*[A-Za-z]/, "", plain)
 116         w = length(plain)
 117         if (widths[i] < w) widths[i] = w
 118 
 119         # handle non-numbers
 120         if (!match(plain, /^[+-]?[0-9]+(\.[0-9]+)?$/)) continue
 121 
 122         numbers[i]++
 123         sums[i] += plain
 124 
 125         # count `dot-decimals` trail in the number
 126         if (!match(plain, /\./)) continue
 127 
 128         dd = w - (RSTART - 1)
 129         if (dot_decs[i] < dd) dot_decs[i] = dd
 130     }
 131 }
 132 
 133 END {
 134     # fix column-widths using number-padding info and the column-totals
 135     for (i = 1; i <= ncols; i++) {
 136         w = 1
 137         if (numbers[i]) {
 138             decs = dot_decs[i] ? dot_decs[i] - 1 : 0
 139             fmt = sprintf("%%.%df", decs)
 140             w = length(sprintf(fmt, sums[i]))
 141         }
 142         if (widths[i] < w) widths[i] = w
 143     }
 144 
 145     if (nrows == 0 || ncols == 0) exit
 146 
 147     # add fake-row with all the column-sums
 148     nrows++
 149     for (i = 1; i <= ncols; i++) {
 150         data[nrows SUBSEP i] = "-"
 151         if (numbers[i]) {
 152             decs = dot_decs[i] ? dot_decs[i] - 1 : 0
 153             fmt = sprintf("%%.%df", decs)
 154             data[nrows SUBSEP i] = sprintf(fmt, sums[i])
 155         }
 156     }
 157 
 158     for (i = 1; i <= nrows; i++) {
 159         n = nitems[i]
 160 
 161         # show tiles, except for the last fake-row with the sums
 162         for (j = 1; i < nrows && j <= n; j++) {
 163             v = data[i SUBSEP j]
 164 
 165             if (v == "") {
 166                 printf "\x1b[0m○"
 167                 continue
 168             }
 169 
 170             if (!match(v, /^[+-]?[0-9]+(\.[0-9]+)?$/)) {
 171                 if (v ~ /^ | $/) printf "\x1b[38;2;196;160;0m■"
 172                 else printf "\x1b[38;2;128;128;128m■"
 173                 continue
 174             }
 175 
 176             if (v > 0) {
 177                 if (match(v, /\./)) printf pdtile
 178                 else printf pitile
 179                 continue
 180             }
 181 
 182             if (v < 0) {
 183                 if (match(v, /\./)) printf "\x1b[38;2;215;95;95m■"
 184                 else printf "\x1b[38;2;204;0;0m■"
 185                 continue
 186             }
 187 
 188             printf "\x1b[38;2;0;95;215m■"
 189         }
 190 
 191         # show tiles for missing trailing fields, except for the fake-row
 192         if (i < nrows) {
 193             extra = ncols - nitems[i]
 194             if (extra > 0) printf "\x1b[0m"
 195             for (j = 1; j <= extra; j++) printf "×"
 196             printf "\x1b[0m  "
 197         } else for (j = 1; j <= ncols + 2; j++) printf " "
 198 
 199         due = 0
 200 
 201         # show/realign row fields
 202         for (j = 1; j <= ncols; j++) {
 203             v = data[i SUBSEP j]
 204 
 205             # put 2-space gaps between columns
 206             if (1 < j) due += 2
 207 
 208             if (v ~ /^ *$/) {
 209                 due += widths[j]
 210                 continue
 211             }
 212 
 213             plain = v
 214             gsub(/\x1b\[[0-9;]*[A-Za-z]/, "", plain)
 215             w = length(plain)
 216 
 217             # handle non-numbers
 218             if (!match(plain, /^[+-]?[0-9]+(\.[0-9]+)?$/)) {
 219                 for (k = 1; k <= due; k++) printf " "
 220                 printf "%s", v
 221 
 222                 due = widths[j] - w
 223                 continue
 224             }
 225 
 226             # count `dot-decimals` trail in the number
 227             dd = match(plain, /\./) ? w - (RSTART - 1) : 0
 228 
 229             rpad = dot_decs[j] - dd
 230             lpad = widths[j] - (w + rpad) + due
 231 
 232             if (plain > 0) rgb = dot_decs[j] ? pdrgb : pirgb
 233             else if (plain < 0) rgb = dot_decs[j] ? "215;95;95" : "204;0;0"
 234             else rgb = "0;95;215"
 235 
 236             for (k = 1; k <= lpad; k++) printf " "
 237             printf "\x1b[38;2;%sm%s\x1b[0m", rgb, v
 238 
 239             due = rpad
 240         }
 241 
 242         # treat extra fields as part of the last one
 243         last = nitems[i]
 244         for (j = ncols + 1; j <= last; j++) printf " %s", data[i SUBSEP j]
 245 
 246         print ""
 247     }
 248 }
 249 ' "$@" \
 250 | sed -E '
 251     s-([0-9]{1,3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})\[0m-\1\2\3\4\5\6\7-g;
 252     s-([0-9]{1,3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})\[0m-\1\2\3\4\5\6-g;
 253     s-([0-9]{1,3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})\[0m-\1\2\3\4\5-g;
 254     s-([0-9]{1,3})([0-9]{3})([0-9]{3})([0-9]{3})\[0m-\1\2\3\4-g;
 255     s-([0-9]{1,3})([0-9]{3})([0-9]{3})\[0m-\1\2\3-g;
 256     s-([0-9]{1,3})([0-9]{3})\[0m-\1\2-g;
 257 '