Пишем аналог column на bash! — Fastenv

Fastenv - системное администрирование от профессионалов индустрии. Работаем с linux, уважаем opensource.

Пишем аналог column на bash!
Сложность: Высокая | Автор: fastenv | March 26, 2016

Бывает в жизни такое настроение, что душа хочет праздника. А так как по долгу деятельности я 80% времени провожу в консоли, то праздника и красоты хочется именно там! И тут на помощь к нам приходят escape последовательности. Они наполняют серый терминал разнообразием и свежестью! Они бибикают, перечеркивают, и расскрашивают текст. Вот лишь небольшая демонстрация их мощи:

for i in 6 1 19 20 5 14 22; do printf "\033[1;2;3$((i%8))m\x$(printf %x $((i+64)))\033[0m"; done; echo

144631210085734

Тема расскраски консоли — избита на просторах интернета. И эта статья совсем не об этом, но мы конечно обсудим, что тут произошло. А поговорим мы о форматировании и напишем свой аналог column. Для начала — пара слов о column. Справочное руководство программы подсказывает отличный пример её целевого назначения:

printf "a:b:c\n1::3\n" | column  -t -s ':'
a  b  c
1     3

всякий раз, когда вы используете column — вы хотите красиво форматировать текст. С целью улучшения читабельности или исскуства ради. Column — хороший инструмент, но как и всё в нашем мире, он не лишен недостатков. Что бы раскрыть эту мысль, поговорим опять про цвета.

В стартовом примере происходит следующее:

  1. Первым делом
     $(printf %x $((i+64)))

    раскрывается в hex код буквы, которую потом следует напечатать. На данном шаге bash запоминает некое число, которое потом подставит в printf.

  2. Далее вычисляется остаток от деления по модулю 8
     $((i%8))

    данный код будет модификатором цвета для escape последовательности.

  3. Наконец bash и ваш терминал танцуют вместе:
    printf
    "\033[1;2;3$((i%8))m     # Это управляющая последовательность, bash честно печатает её как есть,
                             # но ваш терминал реагирует на специальное сочетание байт
                             # и подменяет их на цвет. Точнне, он переключает цвет терминала.
                             # Всё, что будет напечатано далее, будет напечатано этим цветом.
     
    \x$(printf %x $((i+64))) # Мы уже выяснили, что в $(...) содержится hex код буквы.
                             # Теперь мы просим printf подставить
                             # вместо кода саму букву.
     
    \033[0m"                 # Наконец мы просим терминал переключить цветовой режим в исходный.

    Замечу, что в нашем примере нет необходимости после каждой буквы переключать цветовой режим в исходный. Код можно переписать так:

    for i in 6 1 19 20 5 14 22; do printf "\033[1;2;3$((i%8))m\x$(printf %x $((i+64)))"; done; echo -e "\033[0m"

    Результат от этого не изменится т.к. терминал всякий раз честно переключает режим и конструкция «выключить; включить такой-то» выглядит слегка бессмысленно. Я мотивирую такое написание своим опытом и дальнейшими примерами.

Но вернемся к column и добавим немножко музыки красок. Для удобства введем следующие переменные:

COLOR_RESET="\033[0m"
COLOR_RED="\033[1;1;31m"
COLOR_GREEN="\033[1;1;32m"
COLOR_YELLOW="\033[1;1;33m"
COLOR_BLUE="\033[1;1;34m"
COLOR_PINK="\033[1;1;35m"
COLOR_WHITE="\033[1;1;37m"

и попробуем расскрасить пример из man. Я хочу выделить первую «A» т.к. именно с неё начинается моё имя. Ну а зелёный — мой любимый цвет. Пробуем:

printf "${COLOR_GREEN}A${COLOR_RESET}:b:c\n1::3\n" | column  -t -s ':'

column3tr

Аааа..! Перфекционистам рекомендую скорее читать дальше. Ну а с теми кто остался давайте разберёмся. Как я уже говорил, вся интерпретация escape последовательностей идёт на уровне вашего терминала и только. Когда вы передаете вывод в column он видит всё как один текст. К сожалению, он ничего не знает про цвета и специальные последовательности. И в соответсвии со своим кодом честно всё форматрирует. Вставляя нужное количество отступов(пробелов) между словами. Когда же текст «выходит» из column, терминал съедает 19 символов заменяя их на переключение цвета. Можно ли починить column? Увы, никак. Или патчить код или хачить ввод. Например так:

W="${COLOR_WHITE}"
RST="${COLOR_RESET}"
printf "${COLOR_GREEN}A${RST}:${W}b${RST}:${W}c${RST}\n${W}1${RST}:${W} ${RST}:${W}3${RST}\n" | column  -t -s ':'

column4tr

хорошая попытка, но… нет. Мы добавили везде белого цвета, что бы терминал у каждого «слова» украл равное количество символов и всё выглядело невинно. Кроме того, пришлось добавить лишний пробел во второй строке.

Теперь, мой дорогой читатель, тебе ясна мотивация к написанию своей библиотеки форматирования вывода на bash. Приступим!
Определимся с задачей. Когда я что-то форматирую, у меня первая строка — это заголовок. И обычно, я хочу добавить следующую строку и заполнить какие-то из её столбцов. Беспокоиться о том, какой именно это столбец не слишком удобно. Куда приятнее писать по имени. Для примера:

echo "pid=1111 mem=40% cpu=6%" | todo.sh

удобнее, чем

echo -e "pid mem cpu\n1111 40% 6%" | column -t

Так же мне очень нравятся VCS, в частности git и их pull/push.

Всё, техническое задание к разработке:

    1. Написать функции pull_format_msg и push_format_msg.
      1. Первая добавляет сообщение в массив.
      2. Вторая печатает текущий массив на экран.
    2. Функции должны корректно обрабатывать цвета.
    3. Функция pull_format_msg получает на вход строку вида «parm1=value1 .. parmN=valueN», записывает valueN в столбцы с именем parmN.

Создаем дирректорию проекта, инициализируем гит.. и запишем следующее в column.sh:

#!/bin/bash
 
COLOR_RESET="\033[0m"
COLOR_RED="\033[1;1;31m"
COLOR_GREEN="\033[1;1;32m"
COLOR_YELLOW="\033[1;1;33m"
COLOR_BLUE="\033[1;1;34m"
COLOR_PINK="\033[1;1;35m"
COLOR_WHITE="\033[1;1;37m"
format_msg_maxline=1
format_msg_maxpos=0
 
function \
push_format_MSG() {
   local pos parm_name parm_value j
   local max_line=${format_msg_maxline} offset=0
   while [ "$1" ]
   do
      parm_name="${1%%=*}"
      parm_value="${1#*=}"
      for ((j=1; j<=format_msg_maxpos; j++))
      do
         if [ "${FORMAT_MSG[${j}]}" == "${parm_name}" ]
         then
            FORMAT_MSG[$((100*max_line+j))]="${parm_value}"
            break
         fi
      done
      if ((j>format_msg_maxpos))
      then
         FORMAT_MSG[$j]="${parm_name}"
         FORMAT_MSG_LEN[$j]="${#parm_name}"
         FORMAT_MSG[$((100*max_line+j))]="${parm_value}"
         format_msg_maxpos=$j
      fi
      if [ "${FORMAT_MSG[$((100*max_line+j))]:1:4}" = '033[' ]
      then
         offset=19
      else
         offset=0
      fi
      if ((((${#FORMAT_MSG[$((100*max_line+j))]}-offset))>$((${FORMAT_MSG_LEN[${j}]}+0))))
      then
         FORMAT_MSG_LEN[${j}]=$((${#FORMAT_MSG[$((100*max_line+j))]}-offset))
      fi
      shift
   done
   ((format_msg_maxline++))
}

Рассмотрим, что тут происходит:

  1. Объявляем локальные переменные. Локальные — это те, которые существуют в пределах функции.
  2. Далее следует конструкция
    while [ "$1" ]; do ... shift; done

    Смысл у неё простой, цикл выполняется до тех пор, пока в $1 есть значение. А $1 — это первый аргумент скрипта.. Если бы не shift.
    При вызове shift сдвигает вверх указатель на первый элемент. В итоге у нас $1 пробегает последовательно все значения из [email protected]

    1. Конструкция
       "${1%%=*}"

      читается как удалить все символы с конца до знака равенства включительно.

    2. Конструкция
       "${1#*=}"

      читается как удалить все символы с начала до первого знака равенства включительно.

  3. Наконец в коде заполняются два массива:
    1. FORMAT_MSG_LEN — содержащий максимальную длину значения в столбце j.
    2. FORMAT_MSG — содержащий сами значения. Значение имеет две координаты (номер строки, название столбца). К сожалению bash не умеет двумерные массивы, из-за чего приходится их эмулировать: 100*max_line+j. Где max_line — номер строки, а j — номер столбца. Пока столбцов меньше 100 проблемы не возникает. Ну а если возникнет, то мы пропатчим код.)
  4. Про магическую константу 19 мы уже писали.

Половина дела позади! Мы написали функцию, которая добавляет массив. Теперь напишем, которая из массива забирает.
Допишем в column.sh следующий код:

function \
pull_format_MSG() {
   local slen offset=0
   for ((line=0; line<=$format_msg_maxline; line++))
   do
      slen=0
      for ((pos=1; pos<=$format_msg_maxpos; pos++))
      do
         if [ -z "${FORMAT_MSG[$((line*100+pos))]}" ]
         then
            FORMAT_MSG[$((line*100+pos))]=" "
         fi
         if [ "${FORMAT_MSG[$((line*100+pos))]:1:4}" = '033[' ]
         then
            msg_len=$((${#FORMAT_MSG[$((line*100+pos))]}-19))
         else
            msg_len=${#FORMAT_MSG[$((line*100+pos))]}
         fi
         printf "%$((slen+msg_len))b  " "${FORMAT_MSG[$((line*100+pos))]}"
         if [ "${FORMAT_MSG[$((line*100+pos+1))]:1:4}" = '033[' ]
         then
            offset=13
         fi
         slen=$((${FORMAT_MSG_LEN[$pos]}+offset-$msg_len))
         offset=0
      done
      echo
   done
format_msg_maxline=1
format_msg_maxpos=0
unset FORMAT_MSG FORMAT_MSG_LEN
}

Что тут происходит?
Мы пробегаемся по подготовленному массиву и печатаем через printf по нужному смещению. Отдельно рассмотрим printf:

printf "%$((slen+msg_len))b  " "${FORMAT_MSG[$((line*100+pos))]}"

Модификатор b — интерпретировать escape последовательности. Число перед ним — выравнивание справа. В коде мы учитываем, сколько символов уже напечатано и насколько должен быть сдвинут следующий столбец. Домашнее задание. Как так, мы ранее сказали, что интерпретирует их терминал, а тут находится какой-то модификатор b, который на это влияет. А при s — интерпретации не происходит. Почему?

Ну вот и всё!
Код библиотеки доступен на github.

Пример использования:
Создадим fastenv.sh следующего содержания:

#!/bin/bash
 
source "$(dirname $(readlink -f $0))/column.sh"
push_format_MSG Company="${COLOR_BLUE}Fastenv${COLOR_RESET}" Comment="${COLOR_GREEN}Is the best!${COLOR_RESET}"
push_format_MSG Name="${COLOR_WHITE}Artem${COLOR_RESET}" Role="${COLOR_GREEN}Team Lead${COLOR_RESET}" 'Favorite OS'="${COLOR_WHITE}LFS${COLOR_RESET}"
push_format_MSG Name="${COLOR_WHITE}Ludmila${COLOR_RESET}" Role="${COLOR_WHITE}Linux Administrator/{Database,Oracle,Monitoring}${COLOR_RESET}" 'Favorite OS'="${COLOR_WHITE}Ubuntu${COLOR_RESET}"
push_format_MSG Name="${COLOR_WHITE}Dmitriy${COLOR_RESET}" Role="${COLOR_WHITE}Linux Administrator/{Mail,Virtualization}${COLOR_RESET}" 'Favorite OS'="${COLOR_WHITE}Debian${COLOR_RESET}"
push_format_MSG Name="${COLOR_WHITE}Dmitriy${COLOR_RESET}" Role="${COLOR_WHITE}Linux Administrator/{Security,Bash}${COLOR_RESET}" 'Favorite OS'="${COLOR_WHITE}Archlinux${COLOR_RESET}"
push_format_MSG Name="${COLOR_WHITE}Dmitriy${COLOR_RESET}" Role="${COLOR_WHITE}Linux Administrator/{Java,JVM}${COLOR_RESET}" 'Favorite OS'="${COLOR_WHITE}Archlinux${COLOR_RESET}"
push_format_MSG Name="${COLOR_WHITE}Konstantin${COLOR_RESET}" Role="${COLOR_WHITE}Linux Administrator/{Rabbitmq,Git}${COLOR_RESET}" 'Favorite OS'="${COLOR_WHITE}Ubuntu${COLOR_RESET}"
push_format_MSG Name="${COLOR_WHITE}Aleksandr${COLOR_RESET}" Role="${COLOR_WHITE}Linux Administrator/{Network,Haproxy}${COLOR_RESET}" 'Favorite OS'="${COLOR_WHITE}Mint${COLOR_RESET}"
push_format_MSG Name="${COLOR_WHITE}Sergey${COLOR_RESET}" Role="${COLOR_WHITE}Unix Administrator/{*BSD,Database}${COLOR_RESET}" 'Favorite OS'="${COLOR_WHITE}FreeBSD${COLOR_RESET}"
pull_format_MSG

column5tr