Я потратил месяц фул‑тайма, чтобы поиграть и разобраться с макросами. И вот всё, что вам нужно, чтобы быстро стартовать.

В первой части мы установили бета‑версию Dart для экспериментов с макросами, испытали макрос @JsonCodable, который команда Dart выпустила для демонстрации технологии, и написали свой hello‑world макрос.

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

Потребуются знания из первой статьи.

Осторожно: Макросы в Dart это пока эксперимент, и много всего сломается, пока они станут стабильными. Просто мне было слишком интересно.

Начните с кода, который хотите

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

В нашем случае мы хотим применить макрос к такому классу:

@Args()
class HelloArgs {
  final String name;
  final int count;
}

И получить такой сгенерированный код:

class HelloArgsParser {
  final parser = ArgParser();

  HelloArgsParser() {
    _addOptions();
  }

  void _addOptions() {
    parser.addOption("name", mandatory: true);
    parser.addOption("count", mandatory: true);
  }

  HelloArgs parse(List<String> argv) {
    final wrapped = parser.parse(argv);

    return HelloArgs(
      name: wrapped.option("name")!,
      count: int.parse(wrapped.option("count")!),
    );
  }
}

augment class HelloArgs {
  HelloArgs({
    required this.name,
    required this.count,
  });
}

Вот полный код простейшей первой версии макроса, который генерирует это (конкретная ветка по ссылке). Сначала я объясню его, и потом пойдём в дебри обработки ошибок, необязательных аргументов, булевых флагов, списков, enum, значений по умолчанию, справочных текстов и т. п. Но начнём с этой простой версии.

Фазы работы макросов

Макрос не может сделать всю свою работу в один подход, ведь возможно, что у нас несколько макросов, которые хотят модифицировать один и тот же класс. Могут ли они видеть код, сгенерированный друг другом? В каком порядке? Чтобы в этом был порядок, сделали три фазы работы макросов: создание типов, декларации и дефиниции.

Фаза создания типов

Это самая первая фаза. В это время макросы могут создавать новые типы: классы, mixin, enum, typedef и т. п. Макросы могут видеть другие типы, но не получать информацию о них, потому что любой импортированный тип может быть потом перекрыт локальным типом, который другой макрос потом объявит.

Фаза деклараций

К началу фазы деклараций все типы уже созданы и новые создавать нельзя. Это позволяет макросам изучать члены любых типов. В это время можно генерировать новые поля и методы в любых классах. Но кое‑что макросы всё ещё не могут: например, разрешать неявные типы переменных. В этом коде:

final a = b;

b может быть перекрыта локальным геттером с тем же именем, который какой‑нибудь другой макрос создаст позже.

Фаза дефиниций

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

Таким образом, макросы работают от наибольших возможностей с наименьшей видимостью к наибольшей видимости с наименьшими возможностями. Это ограничение позволяет нескольким макросам модифицировать один и тот же код и не знать друг о друге, а также зачастую будет неважен порядок, в котором они работают.

Мы сами решаем, в каких фазах будет работать наш макрос. Это определяется конкретными интерфейсами, которые мы реализуем.

Выбор фаз и интерфейсов

В нашем случае макрос должен работать на таком классе:

@Args()
class HelloArgs {
  final String name;
  final int count;
}

Он должен сгенерировать класс HelloArgsParser с экземпляром ArgParser — стандартного парсера из пакета args, и сконфигурировать его для парсинга нужных параметров командной строки.

Это значит, что нам нужны две фазы из трёх:

  • Чтобы создать класс HelloArgsParser, точно нужна фаза типов.

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

Таким образом, нужно реализовать два интерфейса:

macro class Args implements ClassTypesMacro, ClassDeclarationsMacro {
  @override
  Future<void> buildTypesForClass(
    ClassDeclaration clazz,
    ClassTypeBuilder builder,
  ) async {
    // Здесь создаём класс.
  }
  
  @override
  Future<void> buildDeclarationsForClass(
    ClassDeclaration clazz,
    MemberDeclarationBuilder builder,
  ) async {
    // А здесь конфигурируем парсер и парсим параметры.
  }
}

Если что, интерфейс третьей фазы называется ClassDefinitionsMacro, нам он не нужен.

Макросы можно применять и к другим вещам. Например, чтобы макрос работал на enum, нужен интерфейс EnumTypesMacro, EnumDeclarationsMacro или EnumDefinitionsMacro. Есть ещё много наборов таких интерфейсов, они в API.

Мы видим, что каждый метод получает два параметра:

  1. Декларацию класса, для которого он вызван.

  2. Билдер, специфичный для фазы. Тип билдера как раз и устанавливает ограничения на то, что макрос может делать в каждой фазе.

Программное создание классов

Это легко:

@override
Future<void> buildTypesForClass(
  ClassDeclaration clazz,
  ClassTypeBuilder builder,
) async {
  final name = clazz.identifier.name;
  final parserName = _getParserName(clazz);

  builder.declareType(
    name,
    DeclarationCode.fromString('class $parserName {}\n'),
  );
}

String _getParserName(ClassDeclaration clazz) {
  final name = clazz.identifier.name;
  return '${name}Parser';
}

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

Дополнение классов

В фазе деклараций нужно сделать вот что:

  1. Узнать всё о полях класса с параметрами командной строки.

  2. Создать конструктор.

  3. Дополнить класс парсера, чтобы он парсил, а не был пустым.

@override
Future<void> buildDeclarationsForClass(
  ClassDeclaration clazz,
  MemberDeclarationBuilder builder,
) async {
  final intr = await _introspect(clazz, builder);

  await _declareConstructor(clazz, builder);
  _augmentParser(builder, intr);
}

Интроспекция это долгая история. Давайте сначала предположим, что мы узнали всё о полях и положили это в переменную intr, и сделаем следующие дела.

Добавление конструктора

В нашем классе с параметрами нет конструктора. Давайте сделаем его.

Я сделал макрос @Constructor(), который создаёт конструктор и берёт на себя много мелочей и частных случаев. В нашем макросе Args нужно сделать лишь вот что:

Future<void> _declareConstructor(
  ClassDeclaration clazz,
  MemberDeclarationBuilder builder,
) async {
  await const Constructor().buildDeclarationsForClass(clazz, builder);
}

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

Проходы по полям

Нам нужно пройтись по всем полям два раза:

  1. Чтобы добавить опцию в ArgParser для каждого поля.

  2. Чтобы сгенерировать вызов конструктора с данными из парсера.

В каждом проходе работа с каждым полем зависит от его типа, поэтому удобно использовать шаблон Visitor, чтобы красиво собрать поведение в классы и получить на этапе компиляции гарантию, что на каждом проходе мы обработаем все типы полей.

Начнём с такого класса:

abstract class ArgumentVisitor<R> {
  R visitInt(IntArgument argument);
  R visitString(StringArgument argument);
}

И два класса типов аргументов:

sealed class Argument {
  Argument({
    required this.intr,
    required this.optionName,
  });

  final FieldIntrospectionData intr;
  final String optionName;

  R accept<R>(ArgumentVisitor<R> visitor);
}

class IntArgument extends Argument {
  IntArgument({
    required super.intr,
    required super.optionName,
  });

  @override  R accept<R>(ArgumentVisitor<R> visitor) {
    return visitor.visitInt(this);
  }
}

// То же самое для StringArgument.

Интроспекция

Класс с результатами интроспекции

Для работы макроса нужно собрать много информации: о полях класса с аргументами, об идентификаторах для использования их в сгенерированном коде и т. п.

Удобно собрать всё это в одном классе, чтобы передавать по программе один объект, а не всё это по отдельности.

Назовём этот класс IntrospectionData:

Future<IntrospectionData> _introspect(
  ClassDeclaration clazz,
  MemberDeclarationBuilder builder,
) async {
  final fields = await builder.introspectFields(clazz);
  final ids = await ResolvedIdentifiers.resolve(builder);
  final arguments = await _fieldsToArguments(fields, builder);

  return IntrospectionData(
    arguments: arguments,
    clazz: clazz,
    fields: fields,
    ids: ids,
  );
}

Начинаем с интроспекции полей класса с аргументами. Здесь так много работы и она настолько одинаковая, что я вынес её в пакет macro_util. Поэтому в нашем макросе достаточно одной строки, чтобы получить Map из названия полей в информацию о них:

final fields = await builder.introspectFields(clazz);

Это extension‑метод. Давайте разберём, как он работает, чтобы вы могли это забыть и просто использовать пакет.

Перебор полей

Если есть ClassDeclaration, то вот так можно пройти по всем полям класса:

final List<FieldDeclaration> fields = await builder.fieldsOf(type);

for (final field in fields) {
  // Делаем что-нибудь.
}

FieldDeclaration

Этот класс содержит всю информацию о поле, которая лежит на поверхности и не требует поиска. Это то, что видно прямо из кода поля. В этом объекте есть свойства hasConst, hasFinal, hasStatic, hasInitializer и другие. Самое важное для нас — свойство type, которое возвращает TypeAnnotation.

TypeAnnotation

Это базовый класс для всего, что известно о типе поля без глубокого поиска. Он абстрактный, и конкретный объект зависит от того, как поле объявлено.

Для такого поля:
Foo foo;
мы получаем NamedTypeAnnotation, который хранит ссылку на Foo, не вникая в то, что это такое.

Для такого поля:
var a = 1;
мы получаем OmittedTypeAnnotation. В нём нет ничего полезного, но он является ссылкой, по которой можно разрешить неявный тип в третьей фазе работы макроса.

Есть ещё много подклассов для экзотических полей вроде таких:
final (a, b) = getRecord();
Но нам это не нужно.

Просто убеждаемся, что в каждом поле у нас type является NamedTypeAnnotation и выдаём ошибку, если это не так.

NamedTypeAnnotation

Самое главное, что NamedTypeAnnotation добавляет к базовому TypeAnnotation — это свойство identifier. Для такого поля:
Foo foo;
там будет объект Identifier.

Identifier

Объекты этого класса — это идентификаторы в коде. По сути они содержат название и идею того, что его можно разрешить в какой‑то тип, где‑то объявленный.

В такой декларации:
Foo foo;
в namedTypeAnnotation.identifier будет «Foo» — название и идея того, что оно может быть разрешено во что‑то.

А в этом коде:
print();
в самом начале идёт идентификатор print, который ссылается на функцию в стандартной библиотеке.

В общем, вы поняли идею.

Если мы получили NamedTypeAnnotation с Identifier, у которого name равен «String» или «int», то мы нашли, что хотели — по крайней мере для нашей первой версии.

Там ещё много всего бывает, например, typedef. Всё это мы разберём позже, а пока остановимся на этом. Вот памятка:

И ещё раз: пакет macro_util делает всё это за вас.

Разрешение идентификаторов

Помните, как в первой части этой статьи мы получали идентификатор функции print, чтобы использовать его в сгенерированном коде?

Тут надо сделать то же самое, но для других идентификаторов. Нам понадобятся классы ArgParser, List, String и int. Проще всего собрать эти идентификаторы в одной структуре:

class Libraries {
  static final arg_parser = Uri.parse('package:args/src/arg_parser.dart');
  static final core = Uri.parse('dart:core');
}

class ResolvedIdentifiers {
  ResolvedIdentifiers({
    required this.ArgParser,
    required this.int,
    required this.List,
    required this.String,
  });

  final Identifier ArgParser;
  final Identifier int;
  final Identifier List;
  final Identifier String;

  static Future<ResolvedIdentifiers> resolve(
    MemberDeclarationBuilder builder,
  ) async {
    final (
      ArgParser,
      int,
      List,
      String,
    ) = await (
      builder.resolveIdentifier(Libraries.arg_parser, 'ArgParser'),
      builder.resolveIdentifier(Libraries.core, 'int'),
      builder.resolveIdentifier(Libraries.core, 'List'),
      builder.resolveIdentifier(Libraries.core, 'String'),
    ).wait;

    return ResolvedIdentifiers(
      ArgParser: ArgParser,
      int: int,
      List: List,
      String: String,
    );
  }
}

В идеале мы хотим брать идентификатор ArgParser из его публичной библиотеки package:args/args.dart. Но баг мешает это сделать, поэтому приходится использовать приватную библиотеку:
'package:args/src/arg_parser.dart'

Посмотрите на такой большой класс ResolvedIdentifiers. Можно ли сделать его короче? В нём объявляются поля, и каждое повторяется в конструкторе, а потом для каждого из них мы делаем типовую операцию. Этот кейс кажется знакомым? Если бы только…

Подождите, мы как раз занимаемся макросами, которые должны упрощать именно такие задачи! Давайте применим то, что мы изучили, чтобы облегчить нам работу по созданию макроса!

Я сделал макрос, с которым можно упростить код примерно до такого:

@ResolveIdentifiers()
class ResolvedIdentifiers {
  @Resolve('package:args/args.dart')
  final Identifier ArgParser;

  final Identifier int;
  final Identifier List;
  final Identifier String;
}

// ...

final ids = ResolvedIdentifiers.resolve(builder);

Он в том же пакете macro_util. Но есть проблема. Для базовых типов вроде int, List и String можно зашить, что они в стандартной библиотеке, но ArgParser точно требует ссылку на пакет, которую лучше всего хранить в аннотации к полю, но макросы пока не могут читать аннотации полей.

Поэтому пока @ResolveIdentifiers() подходит только для простейших задач, и нам всё‑таки придётся писать наш класс ResolvedIdentifiers вручную. Но я рад, что в будущем не придётся.

Превращение полей в объекты аргументов

Чтобы наши визторы работали, нужно из полей создать объекты StringArgument и IntArgument:

Map<String, Argument> _fieldsToArguments(
  Map<String, FieldIntrospectionData> fields,
  DeclarationBuilder builder,
) {
  return {
    for (final entry in fields.entries)
      entry.key: _fieldToArgument(
        entry.value as ResolvedFieldIntrospectionData,
        builder: builder,
      ),
  };
}

Argument _fieldToArgument(
  ResolvedFieldIntrospectionData fieldIntr, {
  required DeclarationBuilder builder,
}) {
  final typeDecl = fieldIntr.deAliasedTypeDeclaration;
  final optionName = _camelToKebabCase(fieldIntr.name);
  final typeName = typeDecl.identifier.name;

  switch (typeName) {
    case 'int':
      return IntArgument(
        intr: fieldIntr,
        optionName: optionName,
      );

    case 'String':
      return StringArgument(
        intr: fieldIntr,
        optionName: optionName,
      );
  }
    
  throw Exception();
}

Дополнение парсера

Теперь нужно добавить код в класс парсера. Начнём с этого:

void _augmentParser(
  MemberDeclarationBuilder builder,
  IntrospectionData intr,
) {
  final parserName = _getParserName(intr.clazz);

  builder.declareInLibrary(
    DeclarationCode.fromParts([
      //
      'augment class $parserName {\n',
      '  final parser = ', intr.ids.ArgParser, '();\n',
      ..._getConstructor(intr.clazz),
      ...AddOptionsGenerator(intr).generate(),
      ...ParseGenerator(intr).generate(),
      '}\n',
    ]),
  );
}

Здесь у нас несколько методов, которые генерируют части кода, которые потом объединятся. Напомню, что генерируемые «части» кода это массив из строковых литералов и идентификаторов.

Сначала мы создаём поле parser, которое содержит стандартный парсер, который мы скоро заполним опциями, и потом ещё несколько интересных вещей.

Заполнение парсера опциями

Класс AddOptionsGenerator это визитор аргументов. Он генерирует метод _addOptions():

List<Object> _getConstructor(ClassDeclaration clazz) {
  final parserName = _getParserName(clazz);

  return [
    //
    parserName, '() {\n',
    '  _addOptions();\n',
    '}\n',
  ];
}

class AddOptionsGenerator extends ArgumentVisitor<List<Object>> {
  AddOptionsGenerator(this.intr);

  final IntrospectionData intr;

  List<Object> generate() {
    return [
      //
      'void _addOptions() {\n',
      for (final argument in intr.arguments.values)
        ...[...argument.accept(this), '\n'],
      '}\n',
    ];
  }

  @override
  List<Object> visitInt(IntArgument argument) =>
    _visitStringInt(argument);

  @override  List<Object> visitString(StringArgument argument) =>
    _visitStringInt(argument);

  List<Object> _visitStringInt(Argument argument) {
    return [
      //
      'parser.addOption(\n',
      '  ${jsonEncode(argument.optionName)},\n',
      '  mandatory: true,\n',
      ');\n',
    ];
  }
}

Этот метод _addOptions() вызывается в конструкторе парсера.

Генерация вызова конструктора

Это делает наш второй визитор — ParseGenerator:

class ParseGenerator extends ArgumentVisitor<List<Object>> {
  ParseGenerator(this.intr);

  final IntrospectionData intr;

  List<Object> generate() {
    final name = intr.clazz.identifier.name;
    final ids = intr.ids;

    return [
      //
      '$name parse(', ids.List, '<', ids.String, '> argv) {\n',
      '  final wrapped = parser.parse(argv);\n',
      '  return $name(\n',
      for (final argument in intr.arguments.values) ...[
        ...argument.accept(this),
        ',\n',
      ],
      '  );\n',
      '}\n',
    ];
  }

  @override
  List<Object> visitInt(IntArgument argument) {
    return [
      argument.intr.name,
      ': ',
      intr.ids.int,
      '.parse(wrapped.option(${jsonEncode(argument.optionName)})!)',
    ];
  }

  @override  List<Object> visitString(StringArgument argument) {
    return [
      argument.intr.name,
      ': wrapped.option(${jsonEncode(argument.optionName)})!',
    ];
  }
}

Всё, теперь макросом можно пользоваться!

Создайте такой main.dart:

import 'macro.dart';

@Args()
class HelloArgs {
  final String name;
  final int count;
}

void main(List<String> argv) {
  final parser = HelloArgsParser(); // Сгенерированный класс.
  final HelloArgs args = parser.parse(argv);

  for (int n = 0; n < args.count; n++) {
    print('Hello, ${args.name}!');
  }
}

И запустите его:

$ dart run --enable-experiment=macros lib/main.dart --name=Alexey --count=3
Hello, Alexey!
Hello, Alexey!
Hello, Alexey!

И... Начинаем море улучшений!

Обработка ошибок

Наш макрос пока работает, только если всё в порядке, а иначе показывает непонятные ошибки. Попробуйте добавить поле, которое мы не поддерживаем:

@Args()
class HelloArgs {
  final Map map;
}

Функция _fieldToArgument выбросит исключение. Оно вызовет ещё много ошибок, потому что в классе теперь нет конструктора.

Аккуратное аварийное завершение критически важно в искусстве макросов, потому что пользователь не знает про внутренности. Для этого есть два правила:

  1. Никогда не выбрасывать исключения из макроса, вместо этого генерировать ошибки компиляции.

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

Генерация ошибок компиляции

Вот так можно выдать ошибку:

builder.report(
  Diagnostic(
    DiagnosticMessage(
      'My error',
      target: fieldDeclaration.asDiagnosticTarget,
    ),
    Severity.error,
  ),
);

Это сломает компиляцию и покажет пользователю сообщение. Оно выглядит так же, как обычные ошибки компиляции:

$ dart run --enable-experiment=macros lib/min.dart --name=Alexey --count=3
lib/min.dart:5:16: Error: My error
  final String name;
               ^
lib/min.dart:6:13: Error: My error
  final int count;
            ^

Пакет macro_util позволяет писать это чуть короче:

builder.reportError(
  'My error',
  target: fieldDeclaration.asDiagnosticTarget,
);

Ошибки, которые мы хотим обрабатывать

  • Неподдерживаемые типы.

  • Пропущенные типы.

  • Поля с типами, перекрывающими стандартные int и String.

  • Приватные поля.

  • Поля с инициализаторами.

Вот доработанная версия, которая обрабатывает все эти ошибки. Сделайте diff с прошлой веткой, чтобы увидеть, что поменялось:

Пройдём по этим изменениям.

Скрытие ошибок неподдерживаемых типов

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

Можно обмануть компилятор вот такой переменной:

static var _silenceUninitializedError;

У неё тип dynamic, поэтому её можно без ошибок передать в любой параметр любой функции. Да, это будет ошибка времени выполнения, но мы не дойдём до времени выполнения, потому что выдадим собственную ошибку, которая сломает компиляцию.

Можно ещё передавать в конструктор что‑то вроде
null as dynamic
Но это потребует разрешить идентификатор dynamic, а мне не хотелось этого делать.

Хорошо, а как мы поймём, что нужно передать эту заглушку в параметр конструктора, если у нас только StringArgument и IntArgument? Надо сделать отдельный класс InvalidTypeArgument (и заодно базовый класс для валидных аргументов над int и String):

class InvalidTypeArgument extends Argument {
  InvalidTypeArgument({
    required super.intr,
  }) : super(
          optionName: '',
        );

  @override  R accept<R>(ArgumentVisitor<R> visitor) {
    return visitor.visitInvalidType(this);
  }
}

И нужно добавить в оба визитора обработку этого нового типа. Когда добавляем опции в парсер, ничего не нужно делать:

class AddOptionsGenerator extends ArgumentVisitor<List<Object>> {
  // ...

  @override
  List<Object> visitInvalidType(InvalidTypeArgument argument) {
    return const [];
  }
}

А когда генерируем вызов конструктора, вставим нашу заглушку:

class ParseGenerator extends ArgumentVisitor<List<Object>> {
  // ...

  @override  List<Object> visitInvalidType(InvalidTypeArgument argument) {
    return [
      argument.intr.name,
      ': _silenceUninitializedError',
    ];
  }
}

Теперь нужно создавать объекты InvalidTypeArgument в случае ошибок, а этих случаев много:

Argument _fieldToArgument(
  FieldIntrospectionData fieldIntr, {
  required DeclarationBuilder builder,
}) async {
  final field = fieldIntr.fieldDeclaration;
  final target = field.asDiagnosticTarget;
  final type = field.type;

  void reportError(String message) {
    builder.reportError(message, target: target);
  }

  void unsupportedType() {
    if (type is OmittedTypeAnnotation) {
      reportError('An explicitly declared type is required here.');
      return;
    }
  
    reportError('The only allowed types are: String, int.');
  }

  if (fieldIntr is! ResolvedFieldIntrospectionData) {
    unsupportedType();
    return InvalidTypeArgument(intr: fieldIntr);
  }

  final typeDecl = fieldIntr.deAliasedTypeDeclaration;
  final optionName = _camelToKebabCase(fieldIntr.name);

  if (field.hasInitializer) {
    reportError('Initializers are not allowed for argument fields.');
    return InvalidTypeArgument(intr: fieldIntr);
  }

  if (typeDecl.library.uri != Libraries.core) {
    unsupportedType();
    return InvalidTypeArgument(intr: fieldIntr);
  }

  final typeName = typeDecl.identifier.name;

  switch (typeName) {
    case 'int':
      return IntArgument(
        intr: fieldIntr,
        optionName: optionName,
      );

    case 'String':
      return StringArgument(
        intr: fieldIntr,
        optionName: optionName,
      );
  }

  unsupportedType();
  return InvalidTypeArgument(intr: fieldIntr);
}

Этот код обрабатывает все ошибки, кроме приватных полей. Скоро доберёмся и до них, а пока посмотрим на TypeDeclaration, который мы только что использовали.

Проверка TypeDeclaration у поля

Нам нужно отличать стандартный int от ложного перекрывающего:

class int {}

@Args()
class HelloArgs {
  final int count; // Ложный перекрывающий 'int'.
}

Это значит, что нам недостаточно просто посмотреть на название типа. Нужно ещё понять, из какой он библиотеки. Если из dart:core, то всё в порядке, а иначе нужно показать ошибку. Код выше делает всё это, потому что пакет macro_util делает всю эту работу. Давайте посмотрим, как это происходит.

Помните нашу памятку по типам? У неё есть продолжение:

TypeDeclaration

Помните класс Identifier, который содержит имя «String» или «int»? Кроме имени он содержит ещё и всё необходимое, чтобы узнать библиотеку, в которой этот тип на самом деле объявлен. И вот как можно это узнать:

final typeDecl = await builder.typeDeclarationOf(
  namedTypeAnnotation.identifier,
);

Этот код возвращает TypeDeclaration. Он выгодно отличается от TypeAnnotation, который был просто ссылкой. Теперь у нас есть настоящий тип, вытащенный из библиотеки. Это абстрактный класс, и конкретный будет зависеть от типа.

В простейшем случае мы получим ClassDeclaration. Это то же самое, с чего мы начали копать. Но тогда у нас был ClassDeclaration для того класса, к которому применили макрос, а сейчас он у нас для типа поля. Кстати, если повторять это рекурсивно, то можно исследовать почти всё в программе.

Ещё может встретиться EnumDeclaration. Мы ведь хотим поддерживать enum для типов аргументов, чтобы пользователь мог указывать только значения из определённого набора. Это сложнее, поэтому пока разберёмся с простыми типами.

Ещё может встретиться TypeAliasDeclaration, если тип поля создан с помощью typedef. В этом случае нужно посмотреть на тип, на который он ссылается, и проделывать это, пока не пройдём по всей цепочке typedef — ведь она может закончиться на типе, который мы поддерживаем. Весь процесс выглядит так:

TypeDeclaration typeDecl = await builder.typeDeclarationOf(
  namedTypeAnnotation.identifier,
);

while (typeDecl is TypeAliasDeclaration) {
  final aliasedType = typeDecl.aliasedType;
  if (aliasedType is! NamedTypeAnnotation) {
    // Error. The typedef has led us to something weird like
    // a record:         final (a, b) = getRecord();
    // or a function:    final void Function() function;
    throw Exception('...');
  }
  typeDecl = await builder.typeDeclarationOf(aliasedType.identifier);
}

Поэтому поле, которое мы считывали, называется FieldIntrospectionData.deAliasedTypeDeclaration.

Library

Когда мы получили TypeDeclaration, там есть ссылка на библиотеку, из которой декларация загружена. И вот так мы сможем отличить настоящий int от ложного перекрывающего.

Пакет macro_util package делает всё это. Если всё успешно, то для поля будет возвращён ResolvedFieldIntrospectionData со всей нужной информацией, иначе — просто FieldIntrospectionData с обычным TypeAnnotation.

Кстати, это причина, почему мы назвали базовый класс валидных аргументов ResolvedTypeArgument.

Приватные поля

Что если в классе есть приватное поле?

@Args()
class HelloArgs {
  final String _name;
}

В Dart именованные параметры не могут начинаться с подчёркивания, поэтому макрос, создающий конструктор, делает позиционные параметры для таких полей.

Приватные поля не имеют смысла для класса с данными, поэтому мы их запретим.

Первым делом нужно создать InvalidTypeArgument для такого поля и показать ошибку компиляции:

Argument _fieldToArgument(
  FieldIntrospectionData fieldIntr, {
  required DeclarationBuilder builder,
}) {
  final field = fieldIntr.fieldDeclaration;
  final target = field.asDiagnosticTarget;

  if (fieldIntr.name.contains('_')) {
    builder.reportError(
      'An argument field name cannot contain an underscore.',
      target: target,
    );
    return InvalidTypeArgument(intr: fieldIntr);
  }

  // ...

Дальше нужно разделить именованные и позиционные параметры и передать заглушку для позиционных:

class ParseGenerator extends ArgumentVisitor<List<Object>> {
  ParseGenerator(this.intr);

  final IntrospectionData intr;

  List<Object> generate() {
    final name = intr.clazz.identifier.name;
    final ids = intr.ids;

    final arguments = intr.arguments.values.where(
      (a) =>
        a.intr.constructorHandling ==
        FieldConstructorHandling.namedOrPositional,
    );

    return [
      //
      '$name parse(', ids.List, '<', ids.String, '> argv) {\n',
      '  final wrapped = parser.parse(argv);\n',
      '  return $name(\n',
      for (final param in _getPositionalParams()) ...[...param, ',\n'],
      for (final argument in arguments) ...[
        ...argument.accept(this),
        ',\n',
      ],
      '  );\n',
      '}\n',
    ];
  }

  List<List<Object>> _getPositionalParams() {
    final result = <List<Object>>[];
    final fields = intr.fields.values.where(
      (f) => f.constructorHandling == FieldConstructorHandling.positional,
    );

    for (final _ in fields) {
      result.add([
        '_silenceUninitializedError',
      ]);
    }

    return result;  
}

Здесь мы используем свойство
FieldIntrospectionData.constructorHandling

Оно возвращает одно из двух:

  • positional, если поле начинается с подчёркивания и поэтому в конструкторе может быть только позиционным параметром.

  • namedOrPositional в остальных случаях.

С этой последней правкой макрос наконец‑то обрабатывает все ошибки красиво. Он показывает только собственные ошибки и генерирует синтаксически правильный код.

Поддержка списков и интроспекция параметризованных типов

Наш макрос будет поддерживать списки и сеты из int и String. Для этого нужно ещё расширить иерархию аргументов:

Версия, которая поддерживает всё это — в этой ветке. Сделайте diff с предыдущей веткой, чтобы увидеть, что поменялось:

В основном просто добавились классы аргументов и их обработка в визиторах, но это не про макросы. С точки зрения макросов важно вот что.

Когда мы нашли List или Set, нужно проверить его typeArguments. Это свойство есть только у NamedTypeAnnotation, поэтому нужно проверить тип и выдать ошибки для других аргументов.

Дальше нужно глубоко копнуть в тип параметра — как мы уже делали — и готово:

Future<Argument> _fieldToArgument(
  FieldIntrospectionData fieldIntr, {
  required DeclarationBuilder builder,
}) async {
  // ...

  if (type is! NamedTypeAnnotation) {
    unsupportedType();
    return InvalidTypeArgument(intr: fieldIntr);
  }

  // ...

  switch (typeName) {
    // ...

    case 'List':
    case 'Set':
      final paramType = type.typeArguments.firstOrNull;

      if (paramType == null) {
        reportError(
          'A $typeName requires a type parameter: '
          '$typeName<String>, $typeName<int>.',
        );
        return InvalidTypeArgument(intr: fieldIntr);
      }

      if (paramType.isNullable) {
        reportError(
          'A $typeName type parameter must be non-nullable because each '
          'element is either parsed successfully or breaks the execution.',
        );
        return InvalidTypeArgument(intr: fieldIntr);
      }

      if (paramType is! NamedTypeAnnotation) {
        unsupportedType();
        return InvalidTypeArgument(intr: fieldIntr);
      }

      final paramTypeDecl = await builder.deAliasedTypeDeclarationOf(paramType);

      if (paramTypeDecl.library.uri != Libraries.core) {
        unsupportedType();
        return InvalidTypeArgument(intr: fieldIntr);
      }

      switch (paramTypeDecl.identifier.name) {
        case 'int':
          return IterableIntArgument(
            intr: fieldIntr,
            iterableType: IterableType.values.byName(typeName.toLowerCase()),
            optionName: optionName,
          );

        case 'String':
          return IterableStringArgument(
            intr: fieldIntr,
            iterableType: IterableType.values.byName(typeName.toLowerCase()),
            optionName: optionName,
          );
      }

      // ...

Поддержка Enum

Аргумент enum позволяет пользователю указывать значение только из определённого набора:

@Args()
class HelloArgs {
  final Fruit fruit;
}

enum Fruit { apple, banana, mango, orange }

По идее, когда мы делаем интроспекцию такого поля, мы должны получить EnumDeclaration вместо ClassDeclaration. Но это пока не реализовано. Мы получаем ClassDeclaration даже в этом случае. По‑хорошему, нужно дождаться, когда это заработает. Но можно сделать обходным путём.

На самом деле это большой красный флаг. Когда API начнёт соответствовать спецификации и мы будем получать EnumDelcaration, это сломает код, который ждёт ClassDeclaration. Поэтому пока с этим можно только развлекаться.

Обходной путь заключается в том, чтобы проверить, реализует ли этот класс стандартный интерфейс Enum. Чтобы это сделать, нужен новый инструмент, который мы пока не затрагивали. Давайте добавим его в нашу памятку:

StaticType

Это такое представление типа, которое позволяет сравнивать его с другим StaticType на предмет наследования. Трудно сказать, зачем его придумали и почему мы не можем сравнивать просто два ClassDeclaration. Наверное, StaticType требует какой‑то более дорогой интроспекции. Он однако же не заменяет те классы, которые мы уже разобрали.

Чтобы получить StaticType, нужно попросить билдер разрешить NamedTypeAnnotationCode, содержащий идентификатор типа:

final staticType = await builder.resolve(
  NamedTypeAnnotationCode(name: namedTypeAnnotation.identifier),
);

if (await staticType.isSubtypeOf(enumStaticType)) {
  // ...
}

Теперь когда мы разобрали этот подход, посмотрите на версию, которая поддерживает enum. Сделайте diff с предыдущей версией:

Давайте пройдём по изменениям.

Разрешение StaticType

Пакет macro_utils берёт на себя работу по получению StaticType для каждого поля, поэтому вручную нужно получить его только для стандартного класса Enum.

Будем использовать тот же подход, что и с классом ResolvedIdentifiers, в котором мы собрали все нужные идентификаторы — даже при том, что StaticType нам нужен только один:

import 'package:macros/macros.dart';

import 'resolved_identifiers.dart';

class StaticTypes {
  StaticTypes({
    required this.Enum,
  });

  final StaticType Enum;

  static Future<StaticTypes> resolve(
    MemberDeclarationBuilder builder,
    ResolvedIdentifiers ids,
  ) async {
    final Enum = await builder.resolve(NamedTypeAnnotationCode(name: ids.Enum));

    return StaticTypes(
      Enum: Enum,
    );
  }
}

Теперь добавим этот объект в наш класс с данными интроспекции:

class IntrospectionData {
  IntrospectionData({
    required this.arguments,
    required this.clazz,
    required this.fields,
    required this.ids,
    required this.staticTypes, //                      НОВОЕ
  });

  final Map<String, Argument> arguments;
  final ClassDeclaration clazz;
  final Map<String, FieldIntrospectionData> fields;
  final ResolvedIdentifiers ids;
  final StaticTypes staticTypes; //                    НОВОЕ
}

И добавим эти данные:

Future<IntrospectionData> _introspect(
  ClassDeclaration clazz,
  MemberDeclarationBuilder builder,
) async {
  final ids = await ResolvedIdentifiers.resolve(builder);

  final (fields, staticTypes) = await (
    builder.introspectFields(clazz),
    StaticTypes.resolve(builder, ids), //                  НОВОЕ
  ).wait;

  final arguments = await _fieldsToArguments(
    fields,
    builder: builder,
    staticTypes: staticTypes, //                           НОВОЕ
  );
  
  return IntrospectionData(
    arguments: arguments,
    clazz: clazz,
    fields: fields,
    ids: ids,
    staticTypes: staticTypes, //                           НОВОЕ
  );
}

Создание аргументов enum

Когда мы видим тип не из dart:core, теперь это может быть enum, поэтому добавляем проверки:

Future<Argument> _fieldToArgument(
  FieldIntrospectionData fieldIntr, {
  required DeclarationBuilder builder,
  required StaticTypes staticTypes,
}) async {
  // ...

  if (typeDecl.library.uri != Libraries.core) {
    if (await fieldIntr.nonNullableStaticType.isSubtypeOf(staticTypes.Enum)) {
      return EnumArgument(
        enumIntr:
        await builder.introspectEnum(fieldIntr.deAliasedTypeDeclaration),
        intr: fieldIntr,
        optionName: optionName,
      );
    }

    unsupportedType();
    return InvalidTypeArgument(intr: fieldIntr);
  }

  // ...

  switch (typeName) {
    // ...

    case 'List':
    case 'Set':
      // ...

      if (paramTypeDecl.library.uri != Libraries.core) {
        final paramStaticType = await builder.resolve(paramType.code);
        if (await paramStaticType.isSubtypeOf(staticTypes.Enum)) {
          return IterableEnumArgument(
            enumIntr: await builder.introspectEnum(paramTypeDecl),
            intr: fieldIntr,
            iterableType: IterableType.values.byName(typeName.toLowerCase()),
            optionName: optionName,
          );
        }

        unsupportedType();
        return InvalidTypeArgument(intr: fieldIntr);
      }

      // ...

Интроспекция констант enum

Мы сейчас использовали extension‑метод на билдере:

class EnumIntrospectionData {
  EnumIntrospectionData({
    required this.deAliasedTypeDeclaration,
    required this.values,
  });

  final TypeDeclaration deAliasedTypeDeclaration;
  final List<EnumConstantIntrospectionData> values;
}

class EnumConstantIntrospectionData {
  const EnumConstantIntrospectionData({
    required this.name,
  });

  final String name;
}

extension EnumIntrospectionExtension on DeclarationBuilder {
  Future<EnumIntrospectionData> introspectEnum(
    TypeDeclaration deAliasedTypeDeclaration,
  ) async {
    final fields = await fieldsOf(deAliasedTypeDeclaration);

    final values = (await Future.wait(fields.map(introspectEnumField)))
        .nonNulls
        .toList(growable: false);

    return EnumIntrospectionData(
      deAliasedTypeDeclaration: deAliasedTypeDeclaration,
      values: values,
    );
  }

  Future<EnumConstantIntrospectionData?> introspectEnumField(
    FieldDeclaration field,
  ) async {
    final type = field.type;

    if (type is NamedTypeAnnotation) {
      return null;
    }

    return EnumConstantIntrospectionData(
      name: field.identifier.name,
    );
  }
}

Вообще мы должны были использовать специально для этого сделанный метод builder.valuesOf(enumDeclaration), но у нас нет EnumDeclaration. Поэтому используем наивный подход и просто перебираем все поля в классе. К счастью, все константы enum попадаются в этом переборе. Нам нужно только отбросить коллекцию values, поэтому берём только те поля, для которых тип это NamedTypeAnnotation — он будет false для values. Этот подход не сработает, если в enum есть другие произвольные свойства, но давайте не будем уходить в крайности для нашего простого временного решения.

Кстати, это не входит в пакет macro_util из‑за ненадёжности.

Так или иначе мы получаем объект EnumIntrospectionData и передаём его в EnumArgument:

class EnumArgument extends ResolvedTypeArgument {
  EnumArgument({
    required super.intr,
    required super.optionName,
    required this.enumIntr,
  });

  final EnumIntrospectionData enumIntr;

  @override
  R accept<R>(ArgumentVisitor<R> visitor) {
    return visitor.visitEnum(this);
  }
}

Дальше используем эти значения, когда добавляем опции в парсер:

class AddOptionsGenerator extends ArgumentVisitor<List<Object>> {
  // ...

  @override
  List<Object> visitEnum(EnumArgument argument) {
    final values =
      argument.enumIntr.values.map((v) => v.name).toList(growable: false);

    return [
      //
      'parser.addOption(\n',
      '  ${jsonEncode(argument.optionName)},\n',
      '  allowed: ${jsonEncode(values)},\n',
      '  mandatory: true,\n',
      ');\n',
    ];
  }

  // ...

Парсить данные — совсем просто:

class ParseGenerator extends ArgumentVisitor<List<Object>> {
  // ...

  @override
  List<Object> visitEnum(EnumArgument argument) {
    final valueGetter = _getOptionValueGetter(argument);

    return [
      argument.intr.name,
      ': ',
      argument.intr.deAliasedTypeDeclaration.identifier,
      '.values.byName($valueGetter!)',
    ];
  }

  // ...

И примерно так же для IterableEnumArgument.

Инициализаторы и nullable-поля

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

@Args()
class HelloArgs {
  final int count = 1;
}

Если верить спецификации огментации, мы можем написать такое:

augment class HelloArgs {
  augment final int count = _parse_count(augmented);

  static int _parse_count(int defaultValue) {
    // Парсим данные.
  }
}

Но в настоящее время это ещё не реализовано, и мы пока не можем переписывать инициализаторы. Вместо полноценного решения нужно снова искать обходной путь.

Следующая идея — отказаться от final для таких полей и при создании объекта заполнить только поля без инициализаторов, а затем перезаписать те поля с инициализаторами, для которых в командной строке передано значение.

Это грязновато, потому что конструктор класса не сможет быть константным, поэтому всё‑таки надо будет вернуться и переделать, когда мы наконец сможем переписывать инициализаторы.

Но есть ещё одна проблема. Нам нужно передать значения по умолчанию в вызовы создания опций ArgParser.addOption(defaultsTo: ...), чтобы эти значения появились в справке, которую парсер генерирует. Чтобы это сделать, нам нужно читать инициализаторы, но API позволяет лишь узнать, есть ли инициализатор вообще, но не прочитать его. Проголосуйте за этот запрос, если считаете, что это полезная возможность.

Итак, финальный обходной путь будет таким:

  1. Создаём конструктор только с полями без инициализаторов, чтобы мочь сохранить значения в полях с инициализаторами.

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

  3. Передаём в вызовы addOption(defaultsTo: ...) чтение полей этого мок‑объекта.

Кстати, сейчас удобно сделать и поддержку nullable‑полей, потому что значения по умолчанию и null — это просто два способа обработки отсутствующих значений и их удобно сделать в один заход.

Вот здесь версия, которая поддерживает значения по умолчанию и null. Сделайте diff с прошлой веткой, чтобы увидеть, что поменялось:

Давайте пройдём по изменениям.

Добавление второго конструктора

Сделаем класс MockDataObjectGenerator, чтобы он делал эту работу:

Future<void> _declareConstructors(
  ClassDeclaration clazz,
  MemberDeclarationBuilder builder,
) async {
  await Future.wait([
    const Constructor().buildDeclarationsForClass(clazz, builder),
    MockDataObjectGenerator.createMockConstructor(clazz, builder), // NEW
  ]);
}
const _constructorName = 'withDefaults';

class MockDataObjectGenerator {
  /// Creates the constructor on the data class which does not have
  /// parameters for fields that have initializers
  /// thus keeping them from being overwritten.
  static Future<void> createMockConstructor(
    ClassDeclaration clazz,
    MemberDeclarationBuilder builder,
  ) async {
    return const Constructor(
      name: _constructorName,
      skipInitialized: true,
    ).buildDeclarationsForClass(clazz, builder);
  }
}

Макрос @Constructor, который мы используем, позволяет задать имя для конструктора и пропустить поля с инициализаторами. Так эти поля не будут перезаписаны.

Создание мок-объекта

Давайте ещё доработаем этот класс и сделаем из него третий ArgumentVisitor:

/// Generates code to create an instance of the data class that
/// does not overwrite the fields that have initializers.
///
/// This mock object is then used as the source of data
/// for options that were not passed.
///
/// The required fields are filled with dummy values of the respective types
/// since they will never be used because the actual values for them
/// will be parsed when constructing the end-instance of the data class.
class MockDataObjectGenerator extends ArgumentVisitor<List<Object>>
    with PositionalParamGenerator {

  // ...

  List<Object> generate() {
    final name = intr.clazz.identifier.name;
    final arguments = intr.arguments.values.where(
      (a) =>
        a.intr.constructorOptionality ==
          FieldConstructorOptionality.required &&
        a.intr.constructorHandling ==
          FieldConstructorHandling.namedOrPositional,
    );

    return [
      'static final $fieldName = $name.$_constructorName(\n',
      for (final param in getPositionalParams()) ...[...param, ',\n'],
      for (final parts in arguments.map((argument) => argument.accept(this)))
        ...[...parts, ',\n'],
      ');\n',
    ];
  }

  @override
  List<Object> visitEnum(EnumArgument argument) {
    return [
      argument.intr.name,
      ': ',
      argument.intr.deAliasedTypeDeclaration.identifier,
      '.values.first',
    ];
  }

  @override
  List<Object> visitInt(IntArgument argument) {
    return [
      argument.intr.name,
      ': 0',
    ];
  }

  @override
  List<Object> visitInvalidType(InvalidTypeArgument argument) {
    return [
      argument.intr.name,
      ': _silenceUninitializedError',
    ];
  }

  @override
  List<Object> visitIterableEnum(IterableEnumArgument argument) =>
    _visitIterable(argument);

  @override
  List<Object> visitIterableInt(IterableIntArgument argument) =>
    _visitIterable(argument);

  @override
  List<Object> visitIterableString(IterableStringArgument argument) =>
    _visitIterable(argument);

  @override
  List<Object> visitString(StringArgument argument) {
    return [
      argument.intr.name,
      ': ""',
    ];
  }

  List<Object> _visitIterable(IterableArgument argument) {
    switch (argument.iterableType) {
      case IterableType.list:
        return [
          argument.intr.name,
          ': const []',
        ];

      case IterableType.set:
        return [
          argument.intr.name,
          ': const {}',
        ];
    }
  }
}

Теперь у нас два визитора, которым нужно проходить по позиционным параметрам и передавать туда заглушки, поэтому здесь мы вынесли метод getPositionalParameters mixin PositionalParamGenerator.

Вот столько усилий нужно, чтобы генерировать синтаксически правильный код во всех случаях, чтобы ошибки компиляции сгенерированного кода не пугали пользователя. Это один из важнейших аспектов создания макросов.

Теперь добавляем вызов этого визитора в наш макрос:

void _augmentParser(
  MemberDeclarationBuilder builder,
  IntrospectionData intr,
) {
  final parserName = _getParserName(intr.clazz);

  builder.declareInLibrary(
    DeclarationCode.fromParts([
      //
      'augment class $parserName {\n',
      '  final parser = ', intr.ids.ArgParser, '();\n',
      '  static var _silenceUninitializedError;\n',
      ...MockDataObjectGenerator(intr).generate(), //     НОВОЕ
      ..._getConstructor(intr.clazz),
      ...AddOptionsGenerator(intr).generate(),
      ...ParseGenerator(intr).generate(),
      '}\n',
    ]),
  );
}

Работа со значениями по умолчанию

Прежде всего нужно передать значения по умолчанию в ArgParser. Каждый метод визитора, добавляющий опцию, нужно поменять так:

class AddOptionsGenerator extends ArgumentVisitor<List<Object>> {
  // ...

  List<Object> _visitStringInt(Argument argument) {
    final field = argument.intr.fieldDeclaration;

    return [
      //
      'parser.addOption(\n',
      '  "${argument.optionName}",\n',
      if (field.hasInitializer) ...[            // ИЗМЕНЕНО
        '  defaultsTo: ',                       // ИЗМЕНЕНО
        MockDataObjectGenerator.fieldName,      // ИЗМЕНЕНО
        '.',                                    // ИЗМЕНЕНО
        argument.intr.name,                     // ИЗМЕНЕНО
        '.toString()',                          // ИЗМЕНЕНО
        ',\n',                                  // ИЗМЕНЕНО
      ] else if (!field.type.isNullable)        // ИЗМЕНЕНО
        '  mandatory: true,\n',                 // ИЗМЕНЕНО
      ');\n',
    ];
  }
  
  // ...

И нужно, чтобы каждый метод парсинга учитывал возможность null:

class ParseGenerator extends ArgumentVisitor<List<Object>>
    with PositionalParamGenerator {
  // ...

  @override
  List<Object> visitInt(IntArgument argument) {
    final valueGetter = _getOptionValueGetter(argument);

    return [
      argument.intr.name,
      ': ',
      if (argument.intr.fieldDeclaration.type.isNullable)  // ИЗМЕНЕНО
        '$valueGetter == null ? null : ',                  // ИЗМЕНЕНО
      intr.ids.int,
      '.parse($valueGetter!)',
    ];
  }

  // ...

Как видите, нам не нужно проверять, передано ли значение. Стандартный ArgParser сам вернёт нам или переданное в командной строке, или значение по умолчанию.

Доработка сообщений об ошибках

Теперь у нас может быть более одной ошибки на каждое поле. В этом примере много всего неправильного:

@Args()
class HelloArgs {
  final List<int?>? list = [];
}

Для этого кода нужно выдать три ошибки:

  1. Поле с инициализатором не может быть final, потому что мы должны переписать его значением, полученным из командной строки.

  2. Список не может быть nullable, потому что он будет просто пустым, если не передали ни одного аргумента с этим именем.

  3. Тип в списке тоже не может быть nullable, потому что каждое значение или парсится успешно, или ломает выполнение программы.

Если мы выдадим только одну из этих ошибок, то пользователь исправит её, но сразу же увидит вторую. Он исправит её, но сразу же увидит третью. Он будет думать, что ошибки бесконечные, а макрос кривой и привередливый. Поэтому искусство макросов подразумевает сообщение сразу обо всех ошибках, которые можно найти. И когда пользователь будет исправлять код на такой:

@Args()
class HelloArgs {
  List<int> list = [];
}

ошибки будут исчезать одна за другой, и это — наслаждение.

Чтобы сделать так, нужно:

  1. В функции, распознающей аргумент, сделать локальный флаг isValid.

  2. Когда что‑то пошло не так, установить его в false, но продолжить.

  3. В конце проверить флаг и создать или InvalidTypeArgument, или объект валидного аргумента.

Вот часть изменений (посмотрите diff, там их гораздо больше):

bool isValid = true;

if (field.hasInitializer && field.hasFinal) {
  reportError(
    'A field with an initializer cannot be final '
    'because it needs to be overwritten when parsing the argument.',
  );

  isValid = false;
}

// ...

У хорошего макроса разветвлённое дерево сообщений об ошибках.

Поддержка справочных сообщений

Если запустить программу с флагом --help, она должна показать справку по аргументам. Чтобы это поддерживать, нужно как‑то задать такие тексты. Но как?

Самое красивое — в комментарии:

@Args()
class HelloArgs {
  /// Your name to print.
  final String name;

  /// How many times to print your name.
  final int count = 1;
}
$ dart run --enable-experiment=macros lib/min.dart --help
Usage: [arguments]
    --name (mandatory)    Your name to print.
    --count               (defaults to "1") How many times to print your name.
-h, --help                Print this usage information.

К сожалению, макросы не могут читать комментарии с документацией к полям. Проголосуйте за это предложение, если решение с комментариями кажется вам красивым.

Следующий вариант — хранить эти сообщения в аннотациях к полям:

@Args()
class HelloArgs {
  @Arg(help: 'Your name to print.')
  final String name;

  @Arg(help: 'How many times to print your name.')
  final int count = 1;
}

Не так красиво, но такая аннотация всё равно понадобится, когда мы будем развивать этот макрос, чтобы менять названия опций, накладывать ограничения на диапазон int, и т. п.

Но макросы пока не могут читать аннотации.

Обходной путь — хранить эти тексты в отдельных полях‑константах:

@Args()
class HelloArgs {
  final String name;
  static const _nameHelp = 'Your name to print.';

  int count = 1;
  static const _countHelp = 'How many times to print your name.';
}

В этой ветке версия, которая это поддерживает. Сделайте diff с предыдущей веткой, чтобы увидеть, что поменялось:

А изменения простые:

  1. Игнорировать статические поля (прошлая версия на них ломалась).

  2. При добавлении опции просто подставить название поля‑константы.

Разрешение неявных типов полей

Очень хочется поддерживать такое:

@Args()
class HelloArgs {
  final String name;
  var count = 1; // Неявный тип int.
}

И будет ещё больше хотеться, когда огментация заработает и мы сможем делать финальные поля:

@Args()
class HelloArgs {
  final String name;
  final count = 1; // Неявный тип int.
}

Разрешение неявных полей возможно только в третьей фазе работы макросов. Но, к сожалению, генерировать глобальный код мы могли только во второй фазе:

builder.declareInLibrary(
  DeclarationCode.fromParts([
    'augment class $parserName {',
    // ...
  ]),
);

А в третьей мы можем только переписывать тела методов и инициализаторы в том классе, к которому применён макрос.

Поэтому есть два возможных решения:

  1. Сделать новый макрос, назвав его, например, @ArgsParser, и повесить его на класс парсера в момент его создания. Нужно будет перенести в этот макрос почти всю работу, которую мы проделали. Спецификация позволяет это делать, но это ещё не реализовано. И есть недостаток: этот макрос будет виден пользователю в пространстве имён, но ничего полезного сделать с ним пользователь не сможет.

  2. Отказаться от идеи парсера как отдельного объекта и перенести всю работу в статический метод в классе с аргументами:

@Args()
class HelloArgs {
  final String name;
  final int count;
}

void main(List<String> argv) {
  final args = HelloArgs.parse(argv);
}

Это можно сделать уже сейчас, но мне не нравится перемешивание ответственности. Клиенты объекта с данными не должны иметь доступ к методу parse, которым этот объект создавался.

У меня есть третья идея: взять первый подход, но переиспользовать тот же макрос @Args и для класса‑парсера тоже. Макрос должен посмотреть, к какому классу его применяют, и в зависимости от этого сделать одну из двух работ. Это грязновато, но интерфейс для пользователя будет самым чистым.

Я пока не решил, подожду финальный API, когда макросы станут стабильными. Пока не будем делать разрешение неявных типов.

Но если ваш случай проще, то это делается так:

final typeAnnotation = await builder.inferType(omittedTypeAnnotation);

Тестирование макросов

Юнит-тесты

  1. Сделайте мок ClassDeclaration и билдера для нужной вам фазы.

  2. Создайте экземпляр макроса как обычный объект в коде теста и вызовите интерфейсный метод.

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

Это требует больших усилий, поскольку мок должен возвращать что‑то полезное при разрешении идентификаторов и StaticType. Пожалуй, тут лучше подождать какой‑нибудь кит от команды Dart.

Интеграционные тесты

В случае макроса @Args это гораздо проще:

  1. Пишем несколько программ командной строки.

  2. Вызываем их.

  3. Смотрим их stdout и stderr.

Возьмите главную ветку пакета args_macro. Давайте разберём, как в ней сделаны тесты.

Кстати, в этой версии доделаны все мелочи, которых не хватало: аргументы double, флаги bool и ошибки времени выполнения при парсинге значений. Но это всё не имеет отношения к макросам, поэтому пропустим.

Посмотрите diff:

Все тесты в args_macro_test.dart

В example/lib много маленьких программ, от которых мы ждём или успешное выполнение, или ошибки.

Тестирование ошибок времени выполнения

main.dart — длиннющая программа со всеми 30 видами аргументов, которые мы поддерживаем.

Она может работать успешно или с ошибками — смотря что передавать в командной строке. Её тесты выглядят так:

const _executable = 'lib/main.dart';
const _experiments = ['macros'];
const _workingDirectory = 'example';
const _usageExitCode = 64;

// ...

group('int', () {
  test('missing required', () async {
    final arguments = {..._arguments};
    arguments.remove(_requiredInt);

    final result = await dartRun(
      [_executable, ...arguments.values],
      experiments: _experiments,
      workingDirectory: _workingDirectory,
      expectedExitCode: _usageExitCode,
    );

    expect(
      result.stderr,
      'Option "$_requiredInt" is mandatory.\n\n$_helpOutput',
    );
  }
);

// ...

Эти тесты не имеют отношения к макросам, поэтому не будем их изучать. Однако здесь есть полезная функция dartRun, которую я сделал, чтобы тестировать запуск программ на Dart и ожидать от них чего‑либо. Она в моём пакете test_util.

Тестирование сообщений об ошибках из макроса

Вот эти тесты более интересные:

const _compileErrorExitCode = 254;

// ...

test('error_iterable_nullable', () async {
  await dartRun(
    ['lib/error_iterable_nullable.dart'],
    experiments: _experiments,
    workingDirectory: _workingDirectory,
    expectedExitCode: _compileErrorExitCode,
    expectedErrors: const [
      ExpectedFileErrors('lib/error_iterable_nullable.dart', [
        ExpectedError(
          'A List cannot be nullable because it is just empty '
          'when no options with this name are passed.',
          [7],
        ),
        ExpectedError(
          'A Set cannot be nullable because it is just empty '
          'when no options with this name are passed.',
          [8],
        ),
      ]),
    ],
  );
});

Та же функция dartRun позволяет ожидать сообщения о конкретных ошибках компиляции в конкретных строках.

Это не супер‑надёжно, потому что здесь не используется машиночитаемый вывод, а просто парсится stderr компилятора. Этот парсинг может работать неправильно в необычных случаях или если формат поменяется.

Вот что важно помнить:

  1. Компилятор печатает до 10 ошибок, когда запускаем программу. Это значит, что мы можем ожидать от программы до 9 ошибок (если мы получили 10, то не знаем, была ли 11-я, которую от нас спрятали). И это одна из причин, почему программы в тестах короткие.

  2. Проверять нужно именно результат компилятора, а не анализатора, потому что для новых фич их ошибки могут отличаться. Для примера смотрите этот баг с отличием. И это грустно, потому что анализатор как раз и создавался для этой задачи — получить через Language Server Protocol структурированный список проблем в коде, не ограниченный 10 штуками.

Итог

Макросам далеко до стабильности. Не только API ещё может меняться без предупреждений, но даже спецификация ещё далеко не реализована.

Макросы сейчас — это развлечение и тренажёр изобретательности обходных путей.

Я наслаждался, когда проделывал всё это. Но если бы я знал, что у меня уйдёт месяц, то лучше бы сделал что‑нибудь для прибыли. Ну, хотя бы вы что‑то полезное узнали, значит, не зря.

Не пропускайте мои статьи. Подпишитесь в Тelegram — это мой главный канал. В канале на русском — переводы (с запозданием и не всегда). Ещё Twitter, LinkedIn и GitHub.

Справочник полезностей

Ресурсы от команды Dart:

Мои пакеты:

  • args_macro — тема этой статьи.

  • macro_util — интроспекция полей.

  • show_augmentation — показывает сгенерированный код, если ваша IDE не может.

  • test_util — функция dartRun.

  • common_macros — макрос @Constructor.

  • enum_map — ещё один пример макроса, версия 0.4+, пока pre‑release.

Комментарии (0)