CLI'нический парсинг

Каждый программист однажды получает по голове необходимостью парсить аргументы командной строки. Кого-то этот удар сломит и заставит написать несколько сотен строк кода, по которому потом будут восстанавливать интерфейс программы следующие поколения, другие сделают наборы рантайм мап и начнут оперировать строками, по которым будет уже непонятно, то ли это так и должно быть, то ли опечатка… Последние в порыве отчаяния возьмут целый boost для этой задачи, а тот сведётся всё к тем же рантайм мапам и строкам.

И вот это наконец случилось со мной — так почему бы не использовать это как возможность (написать какую-то дичь) (CLI парсер)? Скажу сразу — мы тут чтобы развлекаться, потому требования к парсеру будут… Интересные

Первый день я лежал и представлял идеальный CLI парсер без изъянов, к вечеру Платоново пространство идей открыло следующие критерии «идеальности»:

  • zero-overhead абстракция, то есть лучше руками написать у вас не выйдет (это отсекает любые аллокации и исключения, например)

  • простое, нет, очень простое подключение и использование. Исключительно один хедер файл, который нужно #include. Никто не станет использовать большой «фреймворк» для парсинга cli

  • удобство описания интерфейса для создающего программу, на компиляции убрать возможные ошибки с одинаковыми/конфликтующими именами опций, неправильным поиском по строке в мапе, неправильный тип опции и так далее

  • удобство использования полученной программы, например сделать так, чтобы --help у программы всегда был, а на опечатки выдавались подсказки по возможным исправлениям

Что есть аргумент?

Итак, в main (судя по типам прямиком из 70х годов прошлого столетия) пришли аргументы, как мы их представим?

int main(int argc, char* argv[])

Вариантов несколько:

  • vector (решение cppfront, rust и прочих не zero overhead языков), нам аллокации запрещены. В конце концов, вдруг кто-то захочет парсить cli аргументы на машине без ОС?…

  • продолжать использовать C-строки

  • немного подумать

Мы выберем конечно немного подумать: внезапно оказывается, что можно просто написать специальный range для входных аргументов программы и потом спокойно оперировать им

using arg = const char*;

using args_t = std::span;

constexpr args_t args_range(int argc, const arg* argv) noexcept {
  // используем некоторые гарантии относительно argc/argv из стандарта С++
  assert(argc >= 0);
  if (argc == 0)
    return args_t{};
  // +1 - пропускаем имя программы, которое не аргумент
  return args_t{argv + 1, argv + argc};
}

Можно конечно подкрутить и сделать random access ренж из string_view, но тогда их придётся каждый раз конструировать заново, чего неочень-то хочется. плюс потеряются некоторые гарантии (да ещё и код писать надо, неприемлемо! )

Curiously recurring header pattern

40b745d96a3ce2a155f5fbc34665342d.png

Лёгкие задачи кончились, начался дизайн — для декларативности и описательности интерфейса решено было, что пользователь создаст свой файл-описание интерфейса программы, этот файл позже может быть использован как документация — ну или просто полюбоваться, не знаю что там ещё они придумают с файлами делать

Файл примерно такой:

d00012f21a380e565a181f979b9bb69e.png

То есть на псевдоязыке имя опции, описание и всякое разное в зависимости от типа опции

Идея в том, чтобы дефайнить макросы TAG/BOOLEAN/ENUM … и потом инклудить файл-описание, впринципе — ничего совсем уж невероятного, но внезапно я вспомнил, что хедер у меня должен быть ровно 1.

А значит — пора использовать С хедеры по максимуму. Забудьте про #pragma once — это наш враг.

С помощью препроцессора мы поделим файл на 3 части:

  1. Часть, в которой находится общий вспомогательный код, она включается 1 раз

  2. Генерирующий код, который инклудит сам себя множество раз и с помощью этого создаёт конечный код

  3. часть, которая инклудится генератором бесконечно много раз

Чтобы было понятнее, вот примерная схема:

#ifndef XXX
#define XXX

<.. вспомогательный код..>
...
  #include __FILE__ // инклуд самого себя
...
  #define INTEGER(<...>) <...>
...
  #include __FILE__ // инклуд самого себя
...
#else // self-include part

#ifndef OPTION
#define OPTION(...)
#endif
// своего рода "наследование" на препроцессоре, если BOOLEAN не определён, то это OPTION
#ifndef BOOLEAN
#define BOOLEAN(...) OPTION(bool, __VA_ARGS__)
#endif

...

// по умолчанию используем такой путь для файла-описания
#ifndef program_options_file
#define program_options_file "program_options.def"
#endif

#include program_options_file
// и добавляем всегда опцию help, которая обрабатывается хедером специально
TAG(help, "list of all options")

#undef BOOLEAN
#undef OPTION
...
#endif

Например, таким образом выглядит создание списка всех опций:

Как видите, IDE почему-то плохо, что ж, мир несовершенен, отправим баг-репорт (нет, я милосерден)

интересно, куда IDE хочет меня отправить за этот код

интересно, куда IDE хочет меня отправить за этот код

Труд

Ключевые решения приняты, всё почти готово и остается лишь взять да и реализовать. Ничего особо интересного (можете посмотреть в исходниках, но там то ещё поле для рефакторинга)

Вот так например выглядит реализация генерации help:

template 
Out print_help_message_to(Out out) {
...
  for_each_option([&](auto o) {
    out(" --"), out(o.name()), out(' '), out(option_arg_str(o));
    const int whitespace_count = 2 + largest_help_string - option_string_len(o);
    for (int i = 0; i < whitespace_count; ++i)
      out(' ');
    out(o.description()), out('\n');
  });
  #define ALIAS(a, b) out(" -" #a " is an alias to " #b "\n");
  #define OPTION(...)
  #include __FILE__
  return ::std::move(out);
}

Здесь, чтобы не зависеть от низменных std: cout и подобных вещей, Out представляет функцию, принимающую всякую всячину и выводящая её куда-нибудь (сами выбирайте куда), а '#a » is an alias to » #b' в макросе это кстати объединение стринг литералов (да, создатели С явно знали что делали)

// то же самое, но без макроса
std::puts( "hello" " " "world");

Дальше идёт реализаций функций parse, структуры с опциями, много обработки ошибок, красивостей для вывода (например нахождения наиболее близкой опции при опечатке, до которого руки не дошли, хотя пишется за 15 минут)

Плоды труда

В итоге мы имеем что-то такое:

Файл описание:

e8220bf7753a1bf31a5488a4da0da681.png

И код использующий этот файл-описание:

46ab4d96b1fd2b37d3c61f2c7801a8c4.png

Как видите, мы смогли переложить на компилятор и IDE большую часть работы, неплохо.

Пройдёмся по целям:

  • ни одной аллокации нет, исключения не бросаются

  • минимализм соблюдён, один хедер на 400 строк (и это можно ещё сократить), использование простое, инклудишь и получаешь готовую структуру и функцию для парсинга по декларативному описанию

  • для пользователя тоже удобно, help генерируется, остальное тоже добавить не проблема. просто я этим не занимался

Кстати, вот как выглядит сгенерированный help:

87a9ca614159c62acd89f0c5f1807b13.png

На этом всё, всем удачи, желаю никогда не увидеть в проде файлы инклудящие сами себя и чтобы IDE вам не предлагала сходить на неймспейс по ссылке

© Habrahabr.ru