yacc (pre-bison) парсер LARL(1) грамматики в bash-скрипт. Реализация jq на bash

#yacc, #bison, #bash, #jq

Автор: Владимир Олейник <dzo@simtreas.ru> (C) 2019

Введение

Иногда возникает проблема написать небольшой умный скрипт, понимающий некую встроенную грамматику, то есть с миниязыком внутрях. Можно, конечно, это написать на, например, C/C++, используя транслятор грамматики вашего языка на bison/yacc. Но это получится бинарник, статический нерасширяемый язык без перекомпиляции тяжелым C/C++-компилятором, да и результат получается хоть и быстрый в работе, но зачастую тоже не маленький по объему получаемого бинарника.

Для быстроты получаемого результата, расширяемости/изменения получаемого языка, а также для встраиваемых систем, где уже есть bash, но gcc явно не нужен, мне всегда хотелось поиметь генератор грамматики, выдающий в качестве результата bash-скрипт. Конечно, сам генератор будет исполняемым бинарником, но небольшим и с его помощью можно генерировать хоть самомодифируемую грамматику.

Последующий текст требует для понимания знакомство с программой bison (yacc), описание её не входит в цель данной статьи.

Реализация yacc (pre-bison) парсер LARL(1) грамматики в bash-скрипт

Исходный код текущей версии парсеров грамматик bison уже вырос до безобразия, потому, порывшись в архивах, я нашел предтечу bison - yacc версии 1.2 1986 года. Он уже умеет все, что необходимо для генерации C-кода из LARL(1) грамматики. На удивление, небольшим количеством правок удалось его изменить для генерации bash-скрипта, заодно причесать K&R код под современный компилятор, устранив многочисленные предупреждения. Итак, в архиве yacc_bash_jq.tgz лежит yacc_bash.c в виде одного небольшого файла с Makefile, для сборки только парсера вызывать make yacc_bash

Так как bash не понимает комплексные типы, то из исходного кода была удалена поддержка %union %type и $<type> синтаксиса. Но добавлена новая опция -i, переключающая тип значений лексем со строк, которые в bash по умолчанию, в целочисленный тип, что упрощает написание скриптов, делая их более человеческими, без дополнительных $ и ${}, если это возможно для вашего языка.

Чтобы не запутаться в значках $, применяемых в yacc-правилах для получения номеров лексем, но являющимся спец-символом bash-а, я поменял в синтаксисе $$, $Number и $-Number значек $ на @.

Так как в оригинальном парсере, все в что находится в C-строках, заключенные в двойные кавычки никакого разбора строки не производится, то для bash, где это происходит, добавлен синтаксис @"строка@". Например, если необходимо получить строку без интерпретации значков @, то пишем echo "строка с @", если надо, чтобы парсер подставил значения @лексем, то пишем echo @"результат=@@@", при этом в выходном скрипте будет сгенерирован код: echo "результат=$yyval".

Заодно подправил код этой старой версии так, чтобы он генерировал последовательность вставки кода как в bison: код в %{ %} вставляется до функции парсинга yyparse(), а код после %% уже копируется в получаемый скрипт до конца файла.

Так как возвращать строки из рекурсивных функций в bash великая проблема, то yylex() должна возвращать целочисленный код лексемы в переменной yychar. Оригинальный код так и делал, но уже присваивал это внутри yyparse() в локальную yychar, тут же пришлось её сделать глобальной.

Реализация упрощенного jq на bash

Изначально я написал минимальную реализацию jq на bash. Но чем больше добавлялось туда «умности», тем труднее приходилось реализовывать рекурсивный разбор подвыражений. Реализовав то, что написано в предыдущей главе, всё пошло как по маслу.

Итак, минимальный yacc_bash_jq.tgz. В архиве помещен Makefile, который собирает посредством make не только jq.sh из jq.yb, но и компилирует yacc_bash.

Получаемый jq.sh имеет синтаксис вызова поддерживаемых функций как у оригинального jq без расширенных возможностей программирования по типу СУБД. Порядок вызова и его возможности можно посмотреть при запуске скрипта без опций.

Пояснения к коду jq.yb

Первая строка объявляет стартовые псевдотокены. Это стандартная рекомендация для возможности вызова yyparse() при нескольких стартовых состояний. У нас их два: разбор фильтра и разбор входных файлов.

Вторая строка указывает наши основные рабочие лексемы.

Третья строка добавляет внутренние рабочие лексемы, например для группировки все функций под лексемой yychar=FUNC, где имя функции будет у значения этой лексемы, например, yylval=keys. Напоминаю, переменная yychar всегда целочисленная, а yylval - строчная, так как вызов парсера производится по умолчанию, без опции -i.

Далее производится установка приоритетов у операций. См. документацию к bison.

Далее внутри bison-обрамления %{ }% приведены реализация функций usage(), yylex() и их вспомогательных подфункций и переменных.

Последующий текст требует для понимания знакомство с программой jq и неплохое владение bash, описание их не входит в цель данной статьи.

Основная функция, вызываемая в теле %% грамматики: node. У неё минимум три аргумента: тип ноды (целочисленный массив N_TYPE[]) - JSON-справочника из входной информации и добавочные типы у парсера фильтра, значение ноды (строчный массив N_V_L[]) - строка JSON-value у JSON-нод или выражений фильтра и остальные значения - список нод до текущей и после текущей, где сама текущая указывается аргументом J. Данный список также помещается в N_V_L[], так как у нас либо нода со списком, либо одиночный JSON-справочник cо значением, например str="value of string". Чтобы избавиться от трудности реализации многомерных массивов в bash, список формируется как номера, с предшествующим пробелом перед каждым.

«Улучшения» и TODO по сравнению с jq

Функции dequote и quote разбирают и снова подготавливают для вывода строки с esc-последовательностями. Моя реализация имеет расширенную, по сравнению с jq поддержку esc-последовательностей как у bash в «echo -e». Что пока не реализовано, так это проверка валидности юникодных кодов \uHHHH.

Моя реализация позволяет не заключать литералы в кавычки при указании ключа у объектов и при их неопциональном поиске в объектах как .[ключи]. Это означает, что запись {null:.[true,null,1,.2]?} будет интерпретирована как {"null":.["true","null","1",".2"]?}. При опциональном поиске .["literal"]? или .[2]? необходимо использовать для успешного поиска только строки для поиска в объектах и целые числа для поиска в массивах как и у jq. По-моему, так логичнее и я сделал это специально.

Предварительное преобразования нецелых чисел во внутреннее представление как в jq не производится, но его можно получить при добавлении нуля +0, отрицанием, умножением на -1 и так далее, так .2+0 будет преобразовано в 0.2, проверка на выход из допустимых значений в bash реализовать весьма трудоемко, пока это печатается в виде предупреждений при сложении и при переполнении получим 0.

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