Apple недавно анонсировала новую библиотеку ArgumentParser, которая использует оболочки свойств и является отличным примером хорошо написанного декларативного API. Но декларативный характер этого имеет некоторые недостатки — в основном, если вам нужно сделать что-то нестандартное, для чего библиотека не предназначена, вам нужно проявить творческий подход.

Введение

Недавно я закончил миграцию с парсера, который используется в библиотеке TSCUtility (в настоящее время все еще используется SPM), на новый для tuist, который помогает в обслуживании и работе с проектами Xcode. Большая часть миграции прошла гладко 🙇‍♂️, за исключением одной команды. Эта команда называется scaffold, и позвольте мне кратко представить ее вам. Когда вы запускаете tuist scaffold framework --name FrameworkName, он создает новый компонент в вашем проекте из шаблона с именем framework. У каждого шаблона есть собственный манифест с именем Template.swift и собственный набор аргументов. Что мы хотим сделать, так это проанализировать tuist scaffold framework, процесс, в котором пользователь создает шаблон с именем framework, затем проанализировать манифест в каталоге framework, добавить его аргументы и позволить ArgumentParser сделать остальное за нас. Уф, это много, мы рассмотрим это шаг за шагом, так что не волнуйтесь.

Начинающийся

Давайте запачкаем руки кодом. 😉 Если вы хотите продолжить, вы можете скачать стартовый проект здесь. Если нет, тоже хорошо. 👍

Вот как изначально выглядит наша реализация команды:

Мы используем пользовательскую функцию preprocess, поскольку мы хотим добавить пользовательские аргументы до начала процесса синтаксического анализа. Это выполняется перед Scaffold.main() с try? Scaffold.preprocess(CommandLine.arguments) в main.swift. Чтобы правильно обрабатывать ошибки, вам нужно будет определить пользовательскую функцию main для ScaffoldCommand, но это выходит за рамки данного руководства. Функция Preprocess теперь находит manifest.json с каталогом из пользовательского ввода и анализирует определенные там атрибуты. В примере проекта у нас есть массив ["name"].

Теперь попробуем запустить scaffold framework --name FrameworkName. Но если вы это сделаете, вы получите следующую ошибку: Error: Unexpected argument 'FrameworkName'. Если подумать, в этом есть смысл — мы не определяем параметр --name, поэтому у ArgumentParser нет шансов успешно проанализировать ввод.

Примечание. О том, как работает ArgumentParser

Здесь нам нужно сделать небольшое отступление, чтобы понять, как ArgumentParser работает под капотом, чтобы иметь возможность внедрять наши динамические аргументы. Мы могли бы свести нашу команду примерно к следующему:

Как вы можете видеть, ArgumentParser каким-то волшебным образом способен распознавать аргументы, которые вы хотите проанализировать, просто из того факта, что вы объявляете их с помощью соответствующей оболочки свойства (в нашем случае @Argument). Если вам нужно полное объяснение всего, что происходит в процессе парсинга, я бы порекомендовал этот замечательный пост. Но нам достаточно знать, что для каждой команды ArgumentParser инициализирует ArgumentSet, что и используется при последующем анализе. Если вы заглянете в исходный код, то вот как выглядит инициализация:

Вот строка, которая нас интересует больше всего: let a: [ArgumentSet] = Mirror(reflecting: type.init()). Другими словами, ArgumentParser перебирает дочерние элементы зеркала команды, и именно так он может волшебным образом распознавать аргументы только из их объявления. 🤯

Использование наших новых знаний

С нашими новыми открытиями мы должны быть в состоянии внедрить наши динамические аргументы. Для этого мы можем использовать CustomReflectable, куда мы передадим наш пользовательский массив дочерних элементов. Но перед этим нам нужно сохранить атрибуты из manifest.json, впрочем, это довольно просто:

Мы просто сохраняем attributes в его аналог static. Мы хотим, чтобы это было static, потому что у нас нет экземпляра команды Scaffold, которую мы могли бы использовать. Увы, теперь мы можем добавить их в реализацию протокола CustomReflectable:

Итак, это немного менее простой код, но давайте рассмотрим его:

В #1 мы просто перебираем объявленный нами массив attributes. В #2 мы инициализируем кортеж (name: String, option: Option<String>). name — это имя атрибута, а что такое Option<String>? Ну, это оболочка свойства, которую вы обычно объявляете следующим образом:

Но мы, очевидно, не можем этого сделать, поэтому нам нужно инициализировать его напрямую. В #3 мы затем создаем Mirror.Child, который мы возвращаем в конце. В #4 мы добавляем наш @Argument template. Для значения дочернего элемента нам нужно передать _template, так мы получаем само свойство, а не его обернутое значение (также известное как Argument<String>). И, наконец, в #5 мы просто возвращаем сам Mirror с нашей инициализированной командой и дочерними элементами attributes и template.

Будем надеяться, что теперь мы сможем просто запустить scaffold framework --name FrameworkName и все пойдет хорошо. Но у меня плохие новости - этого не будет. 😞 Если вы это сделаете, вы получите следующую ошибку:

«Аргумент name определен без соответствующего CodingKey».

CodingKey, ну, похоже, что где-то сбоит декодирование. 🤔

Расшифровка

Если вы посмотрите на определение протокола ParsableCommand, вы увидите, что оно также соответствует ParsableArgument, который затем соответствует Decodable. Но что именно не так? Ну, в нашем пользовательском зеркале мы сказали ArgumentParser, какие аргументы он должен *ожидать*, но он не анализирует сами значения. Если мы посмотрим на наш пример, мы успешно проанализируем наш @Argument template, но не сможем декодировать динамическую опцию --name. Это связано с тем, что ArgumentParser ожидает, что параметр --name будет там, но когда он доберется до его декодирования, он не знает, как это сделать, поскольку он не определен в реализации Decodable, сгенерированной компилятором. Но мы можем это исправить. 💪 Давайте просто определим наш собственный метод init(from decoder: Decoder) throws - во-первых, без наших динамических аргументов:

Большая часть кода может быть вам знакома — мы определяем пользовательский CodingKeys enum, который содержит наш аргумент template, а затем декодируем его как String. Простой! Но мы все еще не обрабатываем наши пользовательские аргументы. ☝️

Мы добавим к нашему CodingKeys enum еще один случай под названием dynamic(String). Это будет инкапсулировать все наши динамические аргументы. К сожалению, Свифт теперь не умеет автоматически конвертировать отдельные случаи в String. Мы протянем ему руку помощи:

Нам нужен только вычисленный stringValue и пользовательский инициализатор из stringValue.

А теперь последняя часть головоломки:

В № 1 мы определяем новое свойство attributes, которое представляет собой словарь [String: String], где ключом будет имя атрибута, а значением будет… ну, значение из пользовательского ввода. В № 2 мы перебираем Scaffold.attributes (не путать с нашим новым свойством) и декодируем их так же, как мы это делали с template — теперь мы просто сохраняем наш результат в наш новый словарь.

Финишная черта

Да, мы приближаемся к финишу. 🏁 Давайте сделаем последнее дополнение к нашему коду и добавим следующее в функцию run:

Теперь, когда вы запустите scaffold framework --name MyFramework, вы увидите:

["имя": "имя_фреймворка"]

И это именно то, что мы хотим, наконец. 🥳 Теперь мы можем даже добавить новый manifest.json с ["platform"] в каталог app. Когда мы вызываем scaffold app --platform iOS, все еще работает!

Резюме того, чего мы только что достигли:

  1. Предварительно обработайте наш ввод и распознайте имя шаблона
  2. Загрузите файл .json в каталог предварительно обработанного шаблона.
  3. Разобрать .json и динамически добавить его атрибуты в ParsableCommand
  4. Создан Swift CLI с ArgumentParser, который теперь может работать с динамическими атрибутами ✅

Я чувствую, что это много. 😉 Вы можете продолжить и поиграть с окончательным проектом, который вы можете найти здесь.

Первоначально опубликовано на https://www.ackee.cz 5 августа 2020 г.