File: ncol-compat.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, -max-columns    use the row with the most items for the item-count
  44 
  45 
  46 # This version is compatible with busybox/alpine-linux.
  47 
  48 maxcols=0
  49 
  50 for arg in "$@"; do
  51     if [ "${arg}" = '--' ]; then
  52         shift
  53         continue
  54     fi
  55 
  56     case "${arg}" in
  57         -h|--h|-help|--help)
  58             awk '/^# +ncol /, /^$/ { gsub(/^# ?/, ""); print }' "$0"
  59             exit 0
  60         ;;
  61 
  62         -m|--m|-maxcols|--maxcols|-maxcolumns|--maxcolumns|-max-columns|\
  63         --max-columns)
  64             maxcols=1
  65             shift
  66             continue
  67         ;;
  68     esac
  69 
  70     break
  71 done
  72 
  73 # show all non-existing files given
  74 failed=0
  75 for arg in "$@"; do
  76     if [ "${arg}" = "-" ]; then
  77         continue
  78     fi
  79     if [ ! -e "${arg}" ]; then
  80         printf "no file named \"%s\"\n" "${arg}" > /dev/stderr
  81         failed=1
  82     fi
  83 done
  84 
  85 if [ "${failed}" -gt 0 ]; then
  86     exit 2
  87 fi
  88 
  89 awk -v maxcols="${maxcols}" '
  90     BEGIN {
  91         if (SUBSEP == "") SUBSEP = "\034"
  92 
  93         # normal positive-style is green, colorblind-friendly positive-style
  94         # becomes the same blue as the zero-style
  95         cb = ENVIRON["COLORBLIND"] != 0 || ENVIRON["COLOR_BLIND"] != 0
  96 
  97         pdtile = cb ? "\x1b[38;2;0;95;215m■" : "\x1b[38;2;0;155;95m■"
  98         pitile = cb ? "\x1b[38;2;0;75;235m■" : "\x1b[38;2;0;135;0m■"
  99         pdrgb = cb ? "0;95;215" : "0;135;95"
 100         pirgb = cb ? "0;75;235" : "0;155;0"
 101     }
 102 
 103     # always ignore trailing carriage-returns
 104     { gsub(/\r$/, "") }
 105 
 106     # first non-empty line auto-detects SSV vs. TSV, and the column-count
 107     ncols == 0 { ncols = NF; if (/\t/) { FS = "\t"; $0 = $0 } }
 108 
 109     ncols > 0 {
 110         if (maxcols && width < NF) width = NF;
 111         nitems[++nrows] = NF
 112 
 113         for (i = 1; i <= NF; i++) {
 114             data[nrows SUBSEP i] = $i
 115 
 116             plain = $i
 117             gsub(/\x1b\[[0-9;]*[A-Za-z]/, "", plain)
 118             w = length(plain)
 119             if (widths[i] < w) widths[i] = w
 120 
 121             # handle non-numbers
 122             if (!match(plain, /^[+-]?[0-9]+(\.[0-9]+)?$/)) continue
 123 
 124             numbers[i]++
 125             sums[i] += plain
 126 
 127             # count `dot-decimals` trail in the number
 128             if (!match(plain, /\./)) continue
 129 
 130             dd = w - (RSTART - 1)
 131             if (dot_decs[i] < dd) dot_decs[i] = dd
 132         }
 133     }
 134 
 135     END {
 136         # fix column-widths using number-padding info and the column-totals
 137         for (i = 1; i <= ncols; i++) {
 138             w = 1
 139             if (numbers[i]) {
 140                 decs = dot_decs[i] ? dot_decs[i] - 1 : 0
 141                 fmt = sprintf("%%.%df", decs)
 142                 w = length(sprintf(fmt, sums[i]))
 143             }
 144             if (widths[i] < w) widths[i] = w
 145         }
 146 
 147         if (nrows == 0 || ncols == 0) exit
 148 
 149         # add fake-row with all the column-sums
 150         nrows++
 151         for (i = 1; i <= ncols; i++) {
 152             data[nrows SUBSEP i] = "-"
 153             if (numbers[i]) {
 154                 decs = dot_decs[i] ? dot_decs[i] - 1 : 0
 155                 fmt = sprintf("%%.%df", decs)
 156                 data[nrows SUBSEP i] = sprintf(fmt, sums[i])
 157             }
 158         }
 159 
 160         for (i = 1; i <= nrows; i++) {
 161             n = nitems[i]
 162 
 163             # show tiles, except for the last fake-row with the sums
 164             for (j = 1; i < nrows && j <= n; j++) {
 165                 v = data[i SUBSEP j]
 166 
 167                 if (v == "") {
 168                     printf "\x1b[0m○"
 169                     continue
 170                 }
 171 
 172                 if (!match(v, /^[+-]?[0-9]+(\.[0-9]+)?$/)) {
 173                     if (v ~ /^ | $/) printf "\x1b[38;2;196;160;0m■"
 174                     else printf "\x1b[38;2;128;128;128m■"
 175                     continue
 176                 }
 177 
 178                 if (v > 0) {
 179                     if (match(v, /\./)) printf pdtile
 180                     else printf pitile
 181                     continue
 182                 }
 183 
 184                 if (v < 0) {
 185                     if (match(v, /\./)) printf "\x1b[38;2;215;95;95m■"
 186                     else printf "\x1b[38;2;204;0;0m■"
 187                     continue
 188                 }
 189 
 190                 printf "\x1b[38;2;0;95;215m■"
 191             }
 192 
 193             # show tiles for missing trailing fields, except for the fake-row
 194             if (i < nrows) {
 195                 extra = ncols - nitems[i]
 196                 if (extra > 0) printf "\x1b[0m"
 197                 for (j = 1; j <= extra; j++) printf "×"
 198                 printf "\x1b[0m  "
 199             } else for (j = 1; j <= ncols + 2; j++) printf " "
 200 
 201             due = 0
 202 
 203             # show/realign row fields
 204             for (j = 1; j <= ncols; j++) {
 205                 v = data[i SUBSEP j]
 206 
 207                 # put 2-space gaps between columns
 208                 if (1 < j) due += 2
 209 
 210                 if (v ~ /^ *$/) {
 211                     due += widths[j]
 212                     continue
 213                 }
 214 
 215                 plain = v
 216                 gsub(/\x1b\[[0-9;]*[A-Za-z]/, "", plain)
 217                 w = length(plain)
 218 
 219                 # handle non-numbers
 220                 if (!match(plain, /^[+-]?[0-9]+(\.[0-9]+)?$/)) {
 221                     for (k = 1; k <= due; k++) printf " "
 222                     printf "%s", v
 223                     due = widths[j] - w
 224                     continue
 225                 }
 226 
 227                 # count `dot-decimals` trail in the number
 228                 dd = match(plain, /\./) ? w - (RSTART - 1) : 0
 229 
 230                 rpad = dot_decs[j] - dd
 231                 lpad = widths[j] - (w + rpad) + due
 232 
 233                 if (plain > 0) rgb = dot_decs[j] ? pdrgb : pirgb
 234                 else if (plain < 0) rgb = dot_decs[j] ? "215;95;95" : "204;0;0"
 235                 else rgb = "0;95;215"
 236 
 237                 for (k = 1; k <= lpad; k++) printf " "
 238                 printf "\x1b[38;2;%sm%s\x1b[0m", rgb, v
 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 ' "$@" | sed -E \
 250     -e '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' \
 251     -e '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' \
 252     -e 's-([0-9]{1,3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})\[0m-\1\2\3\4\5-g' \
 253     -e 's-([0-9]{1,3})([0-9]{3})([0-9]{3})([0-9]{3})\[0m-\1\2\3\4-g' \
 254     -e 's-([0-9]{1,3})([0-9]{3})([0-9]{3})\[0m-\1\2\3-g' \
 255     -e 's-([0-9]{1,3})([0-9]{3})\[0m-\1\2-g'