#!/bin/sh # The MIT License (MIT) # # Copyright (c) 2026 pacman64 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # ncol [options...] [files...] # # Nice COLumns realigns and styles data tables using ANSI color sequences. In # particular, all auto-detected numbers are styled so they're easier to read # at a glance. Input tables can be either lines of space-separated values or # tab-separated values, and are auto-detected using the first non-empty line. # # When not given filepaths to read data from, this tool reads from standard # input by default. # # For positive numbers, a colorblind-friendly blue is used instead of green # if either environment variable COLORBLIND or COLOR_BLIND is declared and set # to 1. # # The options are, available both in single and double-dash versions # # -h, -help show this help message # -m, -max-columns use the row with the most items for the item-count # This version is compatible with busybox/alpine-linux. maxcols=0 for arg in "$@"; do if [ "${arg}" = '--' ]; then shift continue fi case "${arg}" in -h|--h|-help|--help) awk '/^# +ncol /, /^$/ { gsub(/^# ?/, ""); print }' "$0" exit 0 ;; -m|--m|-maxcols|--maxcols|-maxcolumns|--maxcolumns|-max-columns|\ --max-columns) maxcols=1 shift continue ;; esac break done # show all non-existing files given failed=0 for arg in "$@"; do if [ "${arg}" = "-" ]; then continue fi if [ ! -e "${arg}" ]; then printf "no file named \"%s\"\n" "${arg}" > /dev/stderr failed=1 fi done if [ "${failed}" -gt 0 ]; then exit 2 fi awk -v maxcols="${maxcols}" ' BEGIN { if (SUBSEP == "") SUBSEP = "\034" # normal positive-style is green, colorblind-friendly positive-style # becomes the same blue as the zero-style cb = ENVIRON["COLORBLIND"] != 0 || ENVIRON["COLOR_BLIND"] != 0 pdtile = cb ? "\x1b[38;2;0;95;215m■" : "\x1b[38;2;0;155;95m■" pitile = cb ? "\x1b[38;2;0;75;235m■" : "\x1b[38;2;0;135;0m■" pdrgb = cb ? "0;95;215" : "0;135;95" pirgb = cb ? "0;75;235" : "0;155;0" } # always ignore trailing carriage-returns { gsub(/\r$/, "") } # first non-empty line auto-detects SSV vs. TSV, and the column-count ncols == 0 { ncols = NF; if (/\t/) { FS = "\t"; $0 = $0 } } ncols > 0 { if (maxcols && width < NF) width = NF; nitems[++nrows] = NF for (i = 1; i <= NF; i++) { data[nrows SUBSEP i] = $i plain = $i gsub(/\x1b\[[0-9;]*[A-Za-z]/, "", plain) w = length(plain) if (widths[i] < w) widths[i] = w # handle non-numbers if (!match(plain, /^[+-]?[0-9]+(\.[0-9]+)?$/)) continue numbers[i]++ sums[i] += plain # count `dot-decimals` trail in the number if (!match(plain, /\./)) continue dd = w - (RSTART - 1) if (dot_decs[i] < dd) dot_decs[i] = dd } } END { # fix column-widths using number-padding info and the column-totals for (i = 1; i <= ncols; i++) { w = 1 if (numbers[i]) { decs = dot_decs[i] ? dot_decs[i] - 1 : 0 fmt = sprintf("%%.%df", decs) w = length(sprintf(fmt, sums[i])) } if (widths[i] < w) widths[i] = w } if (nrows == 0 || ncols == 0) exit # add fake-row with all the column-sums nrows++ for (i = 1; i <= ncols; i++) { data[nrows SUBSEP i] = "-" if (numbers[i]) { decs = dot_decs[i] ? dot_decs[i] - 1 : 0 fmt = sprintf("%%.%df", decs) data[nrows SUBSEP i] = sprintf(fmt, sums[i]) } } for (i = 1; i <= nrows; i++) { n = nitems[i] # show tiles, except for the last fake-row with the sums for (j = 1; i < nrows && j <= n; j++) { v = data[i SUBSEP j] if (v == "") { printf "\x1b[0m○" continue } if (!match(v, /^[+-]?[0-9]+(\.[0-9]+)?$/)) { if (v ~ /^ | $/) printf "\x1b[38;2;196;160;0m■" else printf "\x1b[38;2;128;128;128m■" continue } if (v > 0) { if (match(v, /\./)) printf pdtile else printf pitile continue } if (v < 0) { if (match(v, /\./)) printf "\x1b[38;2;215;95;95m■" else printf "\x1b[38;2;204;0;0m■" continue } printf "\x1b[38;2;0;95;215m■" } # show tiles for missing trailing fields, except for the fake-row if (i < nrows) { extra = ncols - nitems[i] if (extra > 0) printf "\x1b[0m" for (j = 1; j <= extra; j++) printf "×" printf "\x1b[0m " } else for (j = 1; j <= ncols + 2; j++) printf " " due = 0 # show/realign row fields for (j = 1; j <= ncols; j++) { v = data[i SUBSEP j] # put 2-space gaps between columns if (1 < j) due += 2 if (v ~ /^ *$/) { due += widths[j] continue } plain = v gsub(/\x1b\[[0-9;]*[A-Za-z]/, "", plain) w = length(plain) # handle non-numbers if (!match(plain, /^[+-]?[0-9]+(\.[0-9]+)?$/)) { for (k = 1; k <= due; k++) printf " " printf "%s", v due = widths[j] - w continue } # count `dot-decimals` trail in the number dd = match(plain, /\./) ? w - (RSTART - 1) : 0 rpad = dot_decs[j] - dd lpad = widths[j] - (w + rpad) + due if (plain > 0) rgb = dot_decs[j] ? pdrgb : pirgb else if (plain < 0) rgb = dot_decs[j] ? "215;95;95" : "204;0;0" else rgb = "0;95;215" for (k = 1; k <= lpad; k++) printf " " printf "\x1b[38;2;%sm%s\x1b[0m", rgb, v due = rpad } # treat extra fields as part of the last one last = nitems[i] for (j = ncols + 1; j <= last; j++) printf " %s", data[i SUBSEP j] print "" } } ' "$@" | sed -E \ -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' \ -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' \ -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' \ -e 's-([0-9]{1,3})([0-9]{3})([0-9]{3})([0-9]{3})\[0m-\1\2\3\4-g' \ -e 's-([0-9]{1,3})([0-9]{3})([0-9]{3})\[0m-\1\2\3-g' \ -e 's-([0-9]{1,3})([0-9]{3})\[0m-\1\2-g'