Skip to content

Macro application annotation identification #3728

@lrhn

Description

@lrhn

EDIT: Updated to allow designating a constant variable as a macro application annotation, and removed the attempt to recursively follow variables or type-aliases. Only the macro application declarations that are mentioned in the package configuration will trigger macros when used as annotations.

Compilers need to recognize annotations as macro application annotations, and they need to do so at a point in compilation where the program isn't yet complete (because macros haven't run), which means expression evaluation isn't necessarily possible. The existing source is not a Dart program, and the language only defines semantics for complete and valid Dart programs.

Since macro applications are annotations, and annotations are expressions, we need some way to recognize an expression as representing a macro, and preferably without needing to evaluate another annotation to mark the first annotation class as a macro application.

Here's an attempt to flesh out an idea, based on in-person discussions from the joint meeting week, of defining which declarations are macro application declarations in metadata, completely ouside of the source.

Pubspec/package-config-defined macro annotations

We want to recognize specific annotation expressions as macro application triggers.

We do that by singling out a macro application annotation class or macro application annotation variable, and then considering every annotation which constructs an instance of the class or is a reference to the variable, to be macro application triggers.

Further, we associate a macro implementation with the macro application. There are a number of ways we can do that, I'll mention some and leave that open for now.

The metadata specifying which class is a macro application trigger, and which macro implementation it executes, will be available in the .dart_tool/package_config.json file, as a non-breaking addition to the format. (The format for a program that doesn't depend on any macro-defining packages will be the same as today, and having a macro available will add extra information.)

The information in the package configuration is generated from similar information in the declaring package's pubspec.yaml, and written by the Pub tool along with the rest of the .dart_tool/package_config.json file.

Pubspec format

A package which introduces a macro application class can add (something like) the following to its pubspec.yaml:

macros:
  - 
    application:
       library: macros.dart
       name: MyMacro 
    implementation:
       library: src/macros/my_macro_impl.dart
       name: MyMacroImpl
  - 
     application:
       local_library: tools/test_macro.dart
       name: MyTest
     implementation:
       local_library: tools/test_macro_impl.dart
       name: testMacro

The library value is a path inside the current package's lib/ directory. That is, the path of a package: URI without the leading package:mypackagename/. This file can be referenced by other packages.

The local_library value is a file path relative to the pubspec.yaml file itself, which must not be inside the lib/ directory. It allows a package to declare a local macro for its own use only, for example a macro only used by its own tests.

The name value is an identifier, which should be the name of a top-level declaration inside that library.

Files inside lib/ must not depend on a local macro. (They can't without importing using a file: URI, so that should just come naturally.)

The implementation: (somehow) specifies how to run the implementation code.

Package-config format

This information is written into the package-config.json file for any package which depends on the macro-declaring package.

Local macros are only included for the current package, public macros are included for all packages in the program.

The format for a package: entry is changed to include a macros: [] field:

   {
      "name": "my_package",
      "rootUri": ".../pub_cache/hosted/my_package/1.2.3/",
      "packageUri": "lib/",
      "languageVersion": "3.7",
      "macros": [
        { 
          "application": {
            "library": "macros.dart",
            "name": "MyMacro"
          },
          "implementation": {
            "library": "src/macros/my_macro_impl.dart",
            "name": "MyMacroImpl"
          }
        }
      ]
    }

The library URI is relative to the surrounding package's package: URI root: package:my_package/.
Which means that both the macro application annotation and the macro implementation entry point must be in the same package. (They can always call code from other packages afterwards.)
It's not possible to designate an existing annotation from another package as a macro trigger. No making @override a macro trigger!

A local_library entry can be included for the current package, which has a path relative to the package_config.json file itself. (It can use non-relative file paths, but generally shouldn't. I guess it can technically use a package: URI as the implementation, but it should use library for that.)

  {
    "name": "my_package",
    "rootUri": "../",
    "packageUri": "lib/"
    "macros": [
      {
        "application": {
           "local_library": "../tools/test_macro.dart",
           "name": "MyTest"
        },
        "implementation": {
           "local_library": "../tools/test_macro_impl.dart",
           "name": "testMacro"
        }
      }
    ]
  }

No entry may have a library path for the application and local_library for the implementation.
The other direction is allowed, and is then considered a package local macro.

Tool usage

With this information available, a source processing tool (compiler, analyzer) can recognize a macro application class syntactically. It's an annotation referring to the declaration with the given name in the given library. Above the it's the MyMacro declaration of the package:my_macro/macros.dart library.

Since the program (and even the current library, if it has a macro appliication annotation) is not complete, the macro tool needs to have its own preliminary name resolution, which predicts which declarations, with which names, are exported by which libraries, and which then builds the preliminary import and declaration scopes for each library.
Then metadata annotations are resolved against these preliminary declarations. Since the program is incomplete, not being able to resolve a name is not an error.

The next step is then recognizing source annotations which are instances of that declaration.

To do that, the compiler (I'll use that to cover any program which may run macros) can directly recognize a metadata annotation whose constant expression is either an invocation of a constructor of a macro-application class declaration, or a direct reference to a macro-application constant variable declaration. That is, either:

  • The expression is a constant object creation expression whose type clause (the clause before any .name, (args) and or type arguments in the constructor invocation) denotes a type declaration, and that type declaration's name and declaring library are marked as a macro application in the package configuration. Or,
  • The expression is a (possibly qualified) identifier which resolves to a constant variable declaration, and that variable declaration's name and declaring library are marked as a macro application in the package configuration.

This is very limited, and deliberately so.
There is no conditional expression. You can't do const macro = isWeb ? MyMacro.web() : MyMacro.native(); to choose between two different macro implementations, rather the macro constant itself defines which implementation to use.
One can do const macro = MyMacro(isWeb); and handle the conditional behavior inside the macro implementation.

Notice that the "library and name" denoting a macro application declaration are checked against the resolved declaration's original library, not any library it might have been exported through. There has to be a declaration with the specified name in the specified library, not just in its export scope. (A type alias declaration suffices, the actual class can still be declared in another library.) This design is chosen so that import resolution doesn't have to remember the export path(s) that the name is imported through, only where the declaration was originally written.

Author support

This design requires a macro author to update the pubspec.yaml file every time they make a change to the naming or location of a macro application annotation class or a macro implementation.
That gets old fast.

I propose that the analysis/language server helps with this, by having a @macro annotation added to dart:core.
Then an author can annotate the macro application class as:

@macro("src/macro/impl.dart", "MyMacro")
class MyMacro {
  // ...
}

and get a diagnostic, with a quick-fix, if that doesn't match a macro listing in pubspec.yaml.

That is, the way to write a macro is to write:

@macro("src/macro/impl.dart", "MyMacroImpl") // Path relative to current file.
class MyMacro implements SomeMacroInterfaces {
  const MyMacro();
}

and then have a quick-fix insert the corresponding lines into pubspec.yaml and update package_config.json,
and have another quick-fix create a missing src/macro/impl.dart if it doesn't exist and add a class MyMacroImpl implements ProperMacroInterfaces {} to it.

Probably even allow the implementation to have a back-reference, with the creation quick-fix adding it automatically:

@macro.impl("/macro.dart", "MyMacro")

so that if just one of the @macro annotations get out of sync with declarations, they can be auto-fixed to the new name and location, based on finding the back-link.

Macro implementation execution

The macro implementation is run as a separate Dart program, in a context where it has access to the "macro execution context" that triggers the macro execution, and which allows introspection and code generation. That context is provided as an argument in each step where the macro is executed.

Macro implementation code is completely normal Dart code, with no new features. It may even contain macro applications itself.

Currently the macro implementation is defined a class declaration, which means that it's implicitly constructed by the macro execution context (it must have an unnamed constructor that can be called with zero arguments).
That can still work. There is some set-up that happens before macro execution starts, so it's not unreasonable to allow that set-up to be generated specifically for the macro class, so that it can be instantiated. (Or use dart:mirrors. Or not!)

Other options are:

  • Point to a constant variable declaration which must hold the macro implementation object. Rather than relying on calling a default constructor to create the object, have the author create the instance themselves. Having to be constant also ensures the object isn't stateful, and doesn't keep information between stages. (Unless it cheats and uses global variables, but we can say that we don't promise that later stages are run in the same isolate. And then sometimes not do so.)
  • Point to a not-necessarily-constant getter (can be a final variable, can be a real getter) which produces the instance. That allows an implementation object with state, but we can still say that each phase asks for a new instance, and may not do so in the same isolate, if we want to keep phases separate. (Which we do, because clever recompilation may be able to decide that earlier stages will generate the same result again, so there is no need to actually run them.)
  • Point to any function that can be called with zero arguments, and call it to produce the macro implementation object. Basically the same as getter.

All of these approaches, including class-with-zero-argument-constructor, have the restriction that a macro implementation cannot do asynchronous initialization.
The solution to that is to delay that initialization until the start of phase 1. Probably not a problem in practice.

I suggest using the same restriction as for macro annotations: The name should be the name of a type with a constant unnamed zero-argument constructor, or a constant variable already containing a value.

Annotation Introspection

When the macro implementation runs, it can introspect on the macro application annotation. If that annotation refers to a variable, then the library and name of that variable may be all available information. That should be fine for macros that are not parameterized.

When introspecting on a constant constructor invocation, the macro may be able to get some information about type arguments and arguments, at least their syntax. For type arguments, their name may be enough to refer to the same type again. For argument expressions, some expression structures may be possible to reify as values in the macro implementation (for example something JSON-like, with only list, map and simple value literals). Other arguments may be completely opaque, at least until later phases of macro execution.

Nothing is finalized about this yet.

Post-macro-execution

The macro application annotation is not removed by macro execution, it must still (or finally) be valid as a constant expression in the completed program.

If macro generated declarations change the preliminary binding of a name, either one denoting a macro application declaration, or a name occurring in the arguments to a macro application constructor, then the macro subsystem decides whether that should be considered an error or not. The language doesn't consider the validity of partial programs, it does not have an opinion until the program has been completed by running all macros. That is: Macros do not add any new language features. It relies on the augmentations feature, but that is a separate feature. The macro feature is a tool feature, a Dart program pre-processor that runs on something that may not be a valid or complete Dart program, and therefore not something the Dart language has any opinion on. The macro preprocessor runs completely normal Dart code as macro implementation, and only when that's done and the program is expected to be complete, does the Dart language have any say about it, at which point it too should be a completely normal Dart program.

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureProposed language feature that solves one or more problemsstatic-metaprogrammingIssues related to static metaprogramming

    Type

    No type

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions