Dart code generation

Good afternoon, in this article we will take a closer look at how code generation works in Flutter. The main goal that I faced when writing the article was to analyze each stage of setting up code generation so that the developer who read this material would have a complete picture of the whole process.

What is code generation for?

The main task is to write boilplate code instead of a developer. One example is generating JSON from class fields or vice versa. An example would be a library json_serializable And frozen.

Let’s create our own generator

When creating a generator, the following algorithm is implemented (each item will be described in detail below):

  1. Creating a folder with a pubspec.yaml file inside.

  2. Create an annotations.dart file with entities for annotations (optional if you don’t intend to use annotations).

  3. Creation of the visitor.dart folder, which will store the field, method, and class handlers.

  4. Creation of the generator.dart folder, which implements the method called by build_runner.

  5. Creating a build.dart file that stores an object of type builder that is called by .

  6. Creating a build.yaml file that stores all code generator configurations.

Basic packages

To work, we need two main packages: build_runner And source_gen.

build_runner

This package directly implements code generation. The way it works is as follows: build_runner goes through all the project folders and looks for the build.yaml file. The build.yaml file is the “trigger” for it to fire. Inside this file, all configurations for its further work are stored. Read more about this package Here.

source_gen

source_gen is a package that makes it possible to use classes such as Generator and GeneratorForAnnotation. These classes simplify the process of working with the generator by implementing some functionality inside. Read more about this package Here.

Creating a generator

The generator is a package that connects via pubspec.yaml.

The directory of the project we are creating will look like this.

Project directory

Project directory

1.pubspec.yaml

To get started, let’s create a default TODO project. In the project folder, create a generator folder with the pubspec.yaml file inside. Let’s add the following dependencies to pubspec.yaml.

name: generator 
description: Generator example.
version: 1.0.0

environment:
  sdk: ">=2.18.0-216 <3.0.0"
  flutter: ">=1.17.0"

dependencies:
  flutter:
    sdk: flutter
  build:
  analyzer:
  source_gen:

dev_dependencies:
  build_runner:

You cannot start the package name with the word test, since in this case code generation will not work. Also, when working with yaml, you should use only a space and a line break!

After filling pubspec.yaml in the console, you need to run the following command

$ flutter pub get

New folders will appear in the directory and it will look like this

All pubspec.yaml variables

name – package name

version – package version (if version is not specified, version 0.0.1 is set)

description – a brief description of the package (mandatory in Latin and no more than 180 characters)

homepage – url to the page where the full description of the package is located

repository – url to the repository where the package sources are stored

issue_tracker – url to a resource where users can leave their error messages

documentation – url to the resource where the documentation for the project is located

dependencies – list of packages that are used in the project

dev_dependencies – a list of dependencies that are used only during development (these packages are not downloaded when the application is released to the user)

executables – list of scripts available when activating the package

platforms – list of platforms for which the project is available

publish_to – a resource is indicated here, a project is published kula

funding – a list of resources where users can support the authors of the package / project

false-secrets – a list of files where the service where the packages will be published will not look for leaks of passwords or keys

2. Annotations

In flutter, to create an annotation, you need to create a class with a const constructor. Let’s create another lib folder in the generator folder.

All project files, except for configuration files, should be stored in the lib folder. The reason for this is that when accessed from outside, only the files in that folder will be visible, and files inside the lib won’t be able to reference files outside of it.

Next, in lib, we will create an annotations.dart file with the following content.

class PrintAnn{
  final String data;

  const PrintAnn(this.data);
}

class Sigma {
  const Sigma();
}

Sigma sigmaAnnotation = Sigma();

// возможные аннотации 
@PrintAnn("Hello")
@Sigma
@sigmaAnnotation

3. Visitor

The visitor is needed to process the class elements that come from the builder. Visitor implements the visitor pattern, the essence of which is to extend the functionality of a class without changing the code of the class itself. This visitor library is located in the analyzer package.

To create your own visitor, you need to inherit one of the following 4 classes:

  • GeneralizingElementVisitor – all methods of the class implement recursive visits to element through the visitElement(Element) method. The class also makes it possible to override the visitElement(Element) method, thereby allowing you to customize the process;

  • RecursiveElementVisitor – recursive visiting element is implemented in all class methods;

  • SimpleElementVisitor – all methods are empty;

  • ThrowingElementVisitor – for this class, each method must be overridden. If any method is not implemented, an error will be thrown.

We will inherit the class SimpleElementVisitor, where R is the type of data that will be returned by the class methods. Let’s create a visitor.dart file in the ./lib folder.

import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/visitor.dart';
import 'package:source_gen/source_gen.dart';

import 'annotations.dart';

class Visitor extends SimpleElementVisitor<void> {
  String className="";
  Map<String,String> printData = {};

  @override
  void visitConstructorElement(ConstructorElement element) {
    final elementReturnType = element.type.returnType.toString();
    className = elementReturnType.replaceFirst('*', '');
  }

  @override
  void visitFieldElement(FieldElement element) {
    var instanceName = element.name;
    var data = TypeChecker.fromRuntime(PrintAnn)
            .annotationsOf(element)
            .first
            .getField('data')
            ?.toString() ??
        '';

    printData[instanceName]= data;    
  }
}

The visitor works according to the following principle: When the generator (to be described later), going through the project files (except for the directory of the code generator itself) finds a class that has the necessary annotation, it calls the generateForAnnotatedElement method (actually not, the generate method is called, generateForAnnotatedElement is called further, but it’s easier to understand). The annotated class is passed to the method as an object of type element. In order for visitor to “process” the class, you need to call the method element.visitChildren(visitor).

visitChildren the method goes through all the elements of the class, calling its own handler in visitor for each element. element the model implies that the constructor, methods, fields, etc. have their own types. For example, the class constructor in the element model has the type ConstructorElementnear the field – FieldElement. The entire list of such classes is described Here.

Consider our Visitor.

class Visitor extends SimpleElementVisitor<void> {

Here we indicate that the visitor we created will be of type Visitor and inherit from SimpleElementVisitor. Those. all methods will be implemented, but without functionality. void – the result type of each function.

String className="";
  Map<String,String> printData = {};

Fields that we will need for further work.

@override
  void visitConstructorElement(ConstructorElement element) {
    final elementReturnType = element.type.returnType.toString();
    className = elementReturnType.replaceFirst('*', '');
  }

This method is called when the class constructor is processed. The className field is set to the class name obtained from the constructor.

@override
  void visitFieldElement(FieldElement element) {
    var instanceName = element.name;
    var data = TypeChecker.fromRuntime(PrintAnn)
            .annotationsOf(element)
            .first
            .getField('data') // возвращаем поле с именем data
            ?.toStringValue() ??  // мы знаем, что поле имеет значение типа String и 
        '';                       // поэтому возвращаем String value

    printData[instanceName]= data;    
  }

Here the class fields are processed, which are received as an object of the FieldElement type. To determine if there is an annotation over a variable, the method is used TypeChecker.fromRuntime(PrintAnn).annotationsOf(element). TypeChecker this is an object of the source_gen package that allows you to work with annotations. element is the element we are looking for an annotation on.

 var data = TypeChecker.fromRuntime(PrintAnn) # PrintAnn - класс аннотации, 
											  # который мы хотим определить
            .annotationsOf(element)# element - элемент у которого 
							       # мы ищем аннотацию
            .first
            .getField('data')  # data - имя поля в классе аннотации PrintAnn
            ?.toStringValue() ??# возвращаемый объект типа DartObject 
        '';                     #мы конвертируем в String, если на каком-то из этапов

4. Generator

To implement a generator, you need to create a class that inherits from Generator or GeneratorForAnnotation<T>. These classes are in the source_gen package. In our case, we will inherit from the class GeneratorForAnnotation<T>, where T is the annotation class for which the generator will be called. If annotation is not used, it is necessary to inherit from Generator.

class TestGenerator extends GeneratorForAnnotation<Sigma> {
// когда build_runner находит класс с аннотацией @sigma, 
// вызывется метод generateForAnnotatedElement и тело класса передаётся в аргумент
// в виде объекта Element
  @override
  String generateForAnnotatedElement(
      Element element, ConstantReader annotation, BuildStep buildStep) {
    final visitor = Visitor();
    element.visitChildren(visitor);

    return '';
  }
// рузльтатом работы данной функции будет объект типа String, который будет 
// в сгенерированный файл .g.dart
}

In our case, we will do the following:

From the variables that are annotated with the PrintAnn class, functions should be created that will send the annotation arguments to the console.

import 'package:build/src/builder/build_step.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:generat/annotations.dart';
import 'package:generat/visitor.dart';
import 'package:source_gen/source_gen.dart';

class TestGenerator extends GeneratorForAnnotation<Sigma> {
  @override
  String generateForAnnotatedElement(
      Element element, ConstantReader annotation, BuildStep buildStep) {
    final visitor = Visitor();
    element.visitChildren(visitor);

    var buffer = StringBuffer();

    buffer.writeln("extension \$${visitor.className} on ${visitor.className}{");
    visitor.printData.keys.forEach((element) {
      buffer.writeln(
      "void print_$element(){ print(\"Annotation ${visitor.printData[element]}\");}");
    });

    return buffer.toString();
  }
}

5.build.dart

This file contains a method that returns an object of type Builder. The method is called by build_rinner based on the build.yaml configuration.

import 'package:generat/test_generator.dart';
import 'package:source_gen/source_gen.dart';
import 'package:build/build.dart';

Builder generate(BuilderOptions options) =>
    SharedPartBuilder([TestGenerator()], 'generator');

6.build.yaml

This file is a trigger for triggering build_runner and it stores all configurations for code generation.

#targets предназначен для конфигурирования существующих builders
targets:
  $default: 
    builders: #здесь указывается какой генератор будет настраиваться
      test_generator|generator: #наименование генератора <имя пакета>|<имя генератора>
        enabled: true            #параметры
			source_gen|combining_builder:
				options:
          ignore_for_file:
            - type=lint

# здесь описываются содаваемые генераторы 
builders:
  generator:
    target: ":generator"
    import: "package:test_generator/builder.dart" #файл к котором описана функция generate
    builder_factories: ["generate"]    #название метода, который будет срабатывать при вызове 
    build_extensions: { ".dart": [".g.dart"] }
    auto_apply: dependents
    build_to: cache
    applies_builders: ["source_gen|combining_builder"]

Options

target – the parameter is taken from the second argument of the generate function in builder.dart;

import – a file to which functions from builder_factories are described;

builder_factories – this parameter specifies an array of function names from import, which are called when the generator is triggered;

build_extensions – the parameter specifies the extension of the generated file, for the selected source file type;

auto_apply – indicates to which modules this builder will be additionally applied (I doubt the wording, correct it in the comments if possible);

build_to – where the intermediate data will be stored;

applies_builders – builder, which will be called after the end of the current one.

For applies_builders it is necessary to set the source_gen|combining_builder value, since this builder adds the result to the files in the project.

Testing the generated generator

Add a package to pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  generator:
    path: ./generator/

Next, execute the command

$flutter pub get  

Next, let’s create a test.dart file in the lib folder. Let’s write the following to the file:

import "package:generator/annotation.dart"; // аннотации из нашего пакета
import 'dart:core';

part 'test.g.dart'; // ссылка на сгенерированный в будущем файл

@Sigma()
class TestClass {
  
  @PrintAnn("Hello")
  String message = "message";
}

Execute the command

$flutter pub run build_runner build

As a result, we get a new file test.g.dart in the lib folder

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'test.dart';

// **************************************************************************
// Test1Generator
// **************************************************************************

extension $TestClass on TestClass {
  
  void print_message() {
    print("Annotation Hello");
  }

All!

Sources https://github.com/Majic97/habr_generator .

Similar Posts

Leave a Reply

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