#!/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...] [filenames...] # # 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. # # The only option is the help option, using any of `-h`, `--h`, `-help`, or # `--help`. case "$1" in -h|--h|-help|--help) awk '/^# +ncol /, /^$/ { gsub(/^# ?/, ""); print }' "$0" exit 0 ;; esac [ "$1" = '--' ] && shift command='awk' if [ -e /usr/bin/gawk ]; then command='gawk' fi # 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 ${command} ' # always ignore trailing carriage-returns { gsub(/\r$/, "") } # first non-empty line auto-detects whether input is SSV or TSV num_cols == 0 && /\t/ { FS = "\t"; $0 = $0 } # first non-empty line auto-detects number of table columns num_cols == 0 { num_cols = NF } num_cols > 0 { num_rows++; nitems[num_rows] = NF for (i = 1; i <= NF; i++) { data[num_rows][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 + 0 # count `dot-decimals` trail in the number if (match(plain, /\./)) { 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 <= num_cols; i++) { if (numbers[i] > 0) { decs = dot_decs[i] > 0 ? dot_decs[i] - 1 : 0 w = length(sprintf("%.*f", decs, sums[i])) } else { w = 1 } if (widths[i] < w) widths[i] = w } # add fake-row with all the column-sums num_rows++ for (i = 1; i <= num_cols; i++) { if (numbers[i] > 0) { decs = dot_decs[i] > 0 ? dot_decs[i] - 1 : 0 data[num_rows][i] = sprintf("%.*f", decs, sums[i]) } else { data[num_rows][i] = "-" } } for (i = 1; i <= num_rows; i++) { # show tiles, except for the last fake-row with the sums for (j = 1; i < num_rows && j <= num_cols; j++) { v = data[i][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 "\x1b[38;2;0;155;95m■" else printf "\x1b[38;2;0;135;0m■" 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 < num_rows) { extra = num_cols - nitems[i] if (extra > 0) printf "\x1b[0m" for (j = 1; j <= extra; j++) printf "×" printf "\x1b[0m " } else { if (num_cols > 0) printf "%*s", num_cols + 2, "" } due = 0 # show/realign row fields for (j = 1; j <= num_cols; j++) { v = data[i][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]+)?$/)) { printf "%*s%s", due, "", 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] > 0) ? "0;135;95" : "0;155;0" } else if (plain < 0) { rgb = (dot_decs[j] > 0) ? "215;95;95" : "204;0;0" } else { rgb = "0;95;215" } printf "%*s\x1b[38;2;%sm%s\x1b[0m", lpad, "", rgb, v due = rpad } # treat extra fields as part of the last one last = nitems[i] for (j = num_cols + 1; j <= last; j++) printf " %s", data[i][j] printf "\n" } } ' "$@" | 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})\x1b\[0m-\1\x1b[38;2;168;168;168m\2\x1b[0m\3\x1b[38;2;168;168;168m\4\x1b[0m\5\x1b[38;2;168;168;168m\6\x1b[0m\7-g' \ -e 's-([0-9]{1,3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})\x1b\[0m-\1\x1b[38;2;168;168;168m\2\x1b[0m\3\x1b[38;2;168;168;168m\4\x1b[0m\5\x1b[38;2;168;168;168m\6\x1b[0m-g' \ -e 's-([0-9]{1,3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})\x1b\[0m-\1\x1b[38;2;168;168;168m\2\x1b[0m\3\x1b[38;2;168;168;168m\4\x1b[0m\5-g' \ -e 's-([0-9]{1,3})([0-9]{3})([0-9]{3})([0-9]{3})\x1b\[0m-\1\x1b[38;2;168;168;168m\2\x1b[0m\3\x1b[38;2;168;168;168m\4\x1b[0m-g' \ -e 's-([0-9]{1,3})([0-9]{3})([0-9]{3})\x1b\[0m-\1\x1b[38;2;168;168;168m\2\x1b[0m\3-g' \ -e 's-([0-9]{1,3})([0-9]{3})\x1b\[0m-\1\x1b[38;2;168;168;168m\2\x1b[0m-g'