Writing our own macro in Dart 3.5 instead of the old code generator

Dart 3.5 has a big new feature: macros. It's like old code generation, but directly in memory, without temporary files, plus many more advantages.

This is still a beta and there is little documentation. Here plan Dart commands:

  • Now they have released one macro @JsonCodablewhich replaces the package json_serializable and eliminates .g.dart files. Using his example, you can get acquainted with technology.

  • This macro will become stable during 2024.

  • At the beginning of 2025 it will be possible to write your own macros.

But it turns out that it is possible now: I wrote and published custom macro, and it works—no need to wait until 2025. You can do anything, just don’t use it in production.

So:

  1. Let's look at an example of use @JsonCodable from the Dart team.

  2. Let's write our simplest macro.

  3. Let's take a deep look at my macro, which generates a parser of command line parameters based on the description of your data class.

Preparing an experiment

Dart 3.5

Download the beta version of Dart 3.5 and enable the use of macros according to the official instructions: https://dart.dev/language/macros#set-up-the-experiment

I just downloaded the ZIP file and put it in a separate folder.

VSCode

To see the code that macros produce, you need the latest stable version of the Dart plugin for VSCode.

pubspec.yaml

To use @JsonCodableneed Dart version 3.5.0-154 or higher. Set this requirement in pubspec.yaml:

name: macro_client
environment:
  sdk: ^3.5.0-154

dependencies:
  json: ^0.20.2

analysis_options.yaml

To prevent the analyzer from swearing, tell it that you are experimenting with macros. To do this, create the following analysis_options.yaml:

analyzer:
  enable-experiment:
    - macros

Code

Copy the official example:

import 'package:json/json.dart';

@JsonCodable() // Аннтоация-макрос.
class User {
  final int? age;
  final String name;
  final String username;
}

void main() {
  // Берём такой JSON:
  final userJson = {
    'age': 5,
    'name': 'Roger',
    'username': 'roger1337',
  };

  // Создаём объект с полями и выводим:
  final user = User.fromJson(userJson);
  print(user);
  print(user.toJson());
}

Run the program with the experimental flag in the terminal:

dart run --enable-experiment=macros lib/example.dart

Or configure VSCode to run this way. Open settings.json:

And indicate there:

The example will run and print:

Instance of 'User'
{age: 5, name: Roger, username: roger1337}

This results in only 6 lines in the class:

@JsonCodable()
class User {
  final int? age;
  final String name;
  final String username;
}

And with the package json_serializable this took 16 lines:

@JsonSerializable()
class User {
  const Commit({
    required this.age,
    required this.name,
    required this.username,
  });

  final int? age;
  final String name;
  final String username;

  factory User.fromJson(Map<String, dynamic> map) => _$UserFromJson(map);

  Map<String, dynamic> toJson() => _$UserToJson(this);
}

How to view the generated code

In VSCode, click the “Go to Augmentation” link under the line where the macro is used @JsonCodable. The code will open:

Unlike the old generator, this is not a file on disk – it’s all in memory, so it can’t be edited.

If you change anything in the source file, the generated code will immediately reflect the changes—no need to run anything manually.

And if VSCode is not suitable for you, I wrote programwhich shows exactly the same generated code.

How it works: Ogmentation

The generated code uses a new language feature: augmentation (“augmentation”, translated as “addition”). This is the ability to change a class or function by adding members and replacing function bodies outside the block where they were originally described.

This is a separate syntactic construct not related to macros. Here is the simplest example of its use:

class Cat {
  final String name; // Ошибка "Uninitialized", потому что нет конструктора.
}

augment class Cat {
  Cat(this.name); //    Исправляем эту ошибку.
}

Ogmentation can also be in a separate file. In fact, the main job of the macro is to produce such a file with omentation. And the main difference between macros and the old code generation is that everything happens in memory without temporary .g.dart files on disk.

Therefore, in principle, it would be possible to remake the package json_serializable using ogmentation and even without macros you can get the same short code, because the constructor can be placed in ogmentation, and method forwarders toJson And fromJson no longer needed.

The main honors go to the ogmentations, not the macros. Yes, macros are important, but their role is secondary in the revolution that will soon begin around the language.

Writing our own hello-world macro

Create hello.dart with macro code:

import 'dart:async';

import 'package:macros/macros.dart';

final _dartCore = Uri.parse('dart:core');

macro class Hello implements ClassDeclarationsMacro {
  const Hello();

  @override
  Future<void> buildDeclarationsForClass(
    ClassDeclaration clazz,
    MemberDeclarationBuilder builder,
  ) async {
    final fields = await builder.fieldsOf(clazz);
    final fieldsString = fields.map((f) => f.identifier.name).join(', ');

    final print = await builder.resolveIdentifier(_dartCore, 'print');

    builder.declareInType(
      DeclarationCode.fromParts([
        'void hello() {',
        print,
        '("Hello! I am ${clazz.identifier.name}. I have $fieldsString.");}',
      ]),
    );
  }
}

This macro creates a method hello in any class to which we apply it. The method prints the class name and a list of fields.

A macro is a class with a modifier macro. Here we implement the interface ClassDeclarationsMacro, which tells the compiler that this macro is applicable to classes and is executed at the stage when we generate declarations in them. There are many interfaces that make macros applicable to various other constructs and allow them to work in other stages of code generation. We'll talk about this when we analyze the macro for parsing command line parameters.

The interface has a method buildDeclarationsForClass, which needs to be implemented, and it will be called automatically. Its parameters:

  1. ClassDeclaration with information about the class to which we apply the macro.

  2. A builder that can parse a class declaration and add code to the class or globally to the library.

We use a builder to get a list of fields in a class.

The actual code generation is easy. The builder has a method declareInType, which extends the class with any code. In the simplest case, you can just pass a string, but there is a trick with the function print.

In the example with JsonCodable We saw above that the library dart:core imported with the prefix:

import 'dart:core' as prefix0;

The prefix is ​​added automatically to ensure that your code does not conflict with symbols from this library. The prefix is ​​dynamic and cannot be known in advance. Therefore the challenge print(something) You can't write it in code just as a line. Therefore, we generate the code from parts:

final print = await builder.resolveIdentifier(_dartCore, 'print');

builder.declareInType(
  DeclarationCode.fromParts([ // Части:
    'void hello() {',
    print,
    '("Hello! I am ${clazz.identifier.name}. I have $fieldsString.");}',
  ]),
);

These parts can be strings or references to previously obtained identifiers. At the end, all this will be glued into a string, and the identifiers will receive the necessary prefixes.

The code that our new macro uses:

import 'hello.dart';

@Hello()
class User {
  const User({
    required this.age,
    required this.name,
    required this.username,
  });

  final int? age;
  final String name;
  final String username;
}

void main() {
  final user = User(age: 5, name: 'Roger', username: 'roger1337');
  user.hello();
}

Click “Go to Augmentation” and see the resulting code:

Please note that before print a prefix appeared prefix0under which the library dart:core was imported. By the way, the import itself was added as a side effect of including the identifier print into the code – we did not do this import manually.

Run:

dart run --enable-experiment=macros hello_client.dart

The program will print:

Hello! I am User. I have age, name, username.

Real useful macros

You can study two examples:

JsonCodable

This is a pilot macro from the Dart team to get familiar with the technology. I advise you to disassemble its code. I learned almost everything from him.

Args

This is my macro.

If you write console programs, then you have worked with command line arguments. Usually they are worked with using a standard package args:

import 'package:args/args.dart';

void main(List<String> argv) {
  final parser = ArgParser();
  parser.addOption('name');
  final results = parser.parse(argv);
  print('Hello, ' + results.option('name'));
}

If you run

dart run main.dart --name=Alexey

Then the program will print:

Hello, Alexey

But if there are a lot of arguments, then it is dirty. You can get confused in them. There is no guarantee that you will write them correctly in code. They are difficult to rename because they are string literals.

I did macro Argswhich wraps this standard parser and provides a type safety guarantee:

import 'package:args_macro/args_macro.dart';

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

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}!');
}

I will analyze this package in detail and how I made it in the second part of this article. Subscribe so you don't miss it:

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *