haxetink / tink_macro

The macro toolkit
MIT License
57 stars 18 forks source link
haxe macro tink

Tinkerbell Macro Library

Build Status Gitter Discord

Explained in current marketing speak, tink_macro is the macro toolkit ;)

History and Mission

Historically, this library's predecessor for Haxe 2 started out when macros were a completely new feature. Boldly titled "the ultimate macro utility belt" it implemented reification and expression pattern matching before they were Haxe language features, and added a higher level macro tooling API (for string conversion, expression traversal and what not) to fill in the holes that the standard library left.

As Haxe evolved and some of the functionality has been integrated/reimplemented/superceeded in the standard library or even as first class language feature, the mission of tink_macro has shifted. Rather than being a standalone solution for macro programming, it is now a complement to all the things the Haxe language and the haxe.macro package can do out of the box.

Overview

The library is build on top of the haxe macro API and tink_core, having three major parts:

Macro API

It is suggested to use this API by using tink.MacroApi;

Apart form tink_macro specific things, it will also use haxe.macro.ExprTools and tink.core.Outcome.

Expression Tools

Basic Helpers

Extracting Constants

Shortcuts

Often reification is prefereable to these shortcuts - if applicable. Unlike reification, the position of these expressions will default to Context.currentPos() rather than the position where they were created.

Type Inspection

Advanced Transformations

Position Tools

Type Tools

Function Tools

Operation Tools

Metadata Tools

Build Infrastructure

Writing build macros can sometimes be a little tedious. But tink_macro is here to help!

Member

Let's have a look at the most important type involved in build macros:

typedef Field = {
    var name : String;
    @:optional var doc : Null<String>;
    @:optional var access : Array<Access>;
    var kind : FieldType;
    var pos : Position;
    @:optional var meta : Metadata;
}

No doubt, it gets the job done. There's a few things that could be nicer though. For one, if you want to add something to access and meta, you have to look whether it's not null. Secondly, you can use it to construct non-sensical things like [AInline, ADynamic] or [APublic, APrivate], the former leading to a compiler error and the latter simply being interpreted as private, no matter how many occurrences of APublic you have. And as it is unspecified behavior, it may even change.

For this reason and more, we have tink.macro.Member which looks like this:

abstract Member from Field to Field {
    var name(get, set):String;
    var doc(get, set):Null<String>;
    var kind(get, set):FieldType;
    var pos(get, set):Position;
    var overrides(get, set):Bool;
    var isStatic(get, set):Bool;
    var isPublic(get, set):Null<Bool>;
    var isBound(get, set):Null<Bool>;

    function getFunction():Outcome<Function, Error>;            
    function getVar(?pure = false):Outcome<{ get: String, set: String, type: ComplexType, expr:Expr }, tink.core.Error>;    
    function addMeta(name:String, ?pos:Position, ?params:Array<Expr>):Void; 
    function extractMeta(name:String):Outcome<MetadataEntry, tink.core.Error>;

    function publish():Bool;
    function asField():Field;
}

Most of the API should be self-explaining. The isBound property is a bad name to convey the concept that a field can be either inline (true) or dynamic (false) or neither (null). Equally, isPublic is nullable which means that normally defaults to private.

The publish method will make a field public if it is not explicitly marked as private. This can also be done with if (m.isPublic == null) m.isPublic = true; but the implementation is far more efficient - for what its worth.

The extractMeta method will "peel of" the first tag with a given name - if available. Note that the tag will be removed from the member.

The getVar method will get information about the field if it is a variable or yield failure otherwise. If pure is set to true, it will fail for properties also.

At any time you can also use asField to interact with the data the good old way. Converting between Member and Field is without overhead.

ClassBuilder

To make handling multiple fields easier, we have the ClassBuilder with the following API:

class ClassBuilder {    
    var target(default, null):ClassType;
    function new():Void;

    function getConstructor(?fallback:Function):Constructor;
    function hasConstructor():Bool;

    function export(?verbose = false):Array<Field>;
    function iterator():Iterator<Member>;   

    function hasSuperField(name:String):Bool;
    function hasOwnMember(name:String):Bool;
    function hasMember(name:String):Bool; 
    function removeMember(member:Member):Bool;
    function addMember(m:Member, ?front:Bool = false):Member;

    static public function run(plugins:Array<ClassBuilder->Void>, ?verbose = false)
}

The first thing to point out is that constructors are handled separately. This is covered in the documentation of Constructor.

As for the rest of the members, you can just iterate over them. It's worth noting that the iterator runs over a snapshot made at the time of its creation, so removing and adding fields during iteration has no effect on the iteration itself.

You can add a member. If you try adding a member named "new", you'll get an exception - so don't. Find out about how tink_macro handles constructors below. If you add a member that already exists in the super class, the override is added automatically.

And when you're done, you can export everything to an array of fields. If you set verbose to true, you will get compiler warnings for every generated field at the position of the field. This is way you can see the generated code even if the application cannot compile for some reason.

The intended use is with run that will send the same ClassBuilder through a number of functions, exporting once at the end. This reduces the overhead introduced by the ClassBuilder.

Constructor

Constructors are relatively tricky, especially when you have inheritance. If you do not specify a constructor, than that of the the super class is used. If you do specify one, then it needn't be compatible with the super class, but it needs to call it. Macros represent them as an instance field called new that must be a function. However if you think about it, a constructor belongs to a class, not an instance. So this is all a little dodgy. The constructor API is an attempt to create a more rigid solution.

The Constructor API is the result of countless struggles with constructors. Still it may not be for you. In that case feedback is appreciated and currently the suggested method is to deal with the constructor after you've exported all fields from the ClassBuilder.

Constructors are represented by this API:

class Constructor {
    var isPublic:Null<Bool>;
    function publish():Void; 
    function addStatement(e:Expr, ?prepend = false):Void;
    function addArg(name:String, ?t:ComplexType, ?e:Expr, ?opt = false)
    function init(name:String, pos:Position, with:FieldInit, ?options:{ ?prepend:Bool, ?bypass:Bool }):Void;
    function onGenerate(hook:Function->Void):Void;
}

Creation

You get a Constructor by calling getConstructor on a ClassBuilder. If the class that you're operating on has a constructor, the Constructor will be created from that. If not, it will be created on demand. The hasConstructor method indicates whether a constructor has already been created.

When a Constructor is created automatically and without fallback a call to the super constructor is auto-generated (assuming the class has a super class that has a constructor) forwarding all arguments.

Visibility

The constructor starts out without private or public. Use isPublic and publish to control visibility analogously to Member.

Initial Super Call

If the first statement in a constructor is a super call (which is true for automatically generated ones), then modification of the constructor through this API will maintain that property. Generally, that's also the suggested way to go. If you need to execute things before that's a symptom of a fragile base class. Still, if absolutely want to do it, the slightest modification can be used to not match the super call detection. If the first statement is @later super(...) or (super(...)) or whatever that is not an immediate call to super, then it will not be detected as a super call and will not be treated specially.

Simple Modifications

Adding any statements to the constructor is unsurprisingly achieved by addStatement. Setting prepend to true, you can add the statement at the very beginning of the constructor, but after the super call if one was detected. Again, relying on order can be indicative of a fragile design.

To add a constructor argument, you can just use addArg.

Field Initialization

The init method is the swiss army knife of initializing fields. The options.prepend flag works the same as prepend for addStatement. As for options.bypass, the behavior is somewhat magical.

Setter Bypass

It is important to know that when you initialize a field with options.bypass set to true, existing setters will be bypassed. That's particularly helpful if your setter triggers a side effect that you don't want triggered. This is achieved by generating the assignment as (untyped this).$name = $value. To make the code typesafe again, this is prefixed with if (false) { var __tmp = this.$name; __tmp = $value; }. This code is later thrown out by the compiler. Its role is to ensure type safety without interfering with the normal typing order.

Setter bypass also causes the field to gain an @:isVar. And currently, with -dce full, additional code will be generated to avoid the field to be eliminated.

Please do note, that value will be in the generated code twice, therefore if it is an expression that calls a macro, the macro will be called twice.

Initialization Options

The different options for initialization are as follows:

enum FieldInit {
    Value(e:Expr);
    Arg(?t:ComplexType, ?noPublish:Bool);
    OptArg(?e:Expr, ?t:ComplexType, ?noPublish:Bool);
}

Here, Value will just use a plain expression, whereas Arg and OptArg will use a mandatory or optional argument respectively. Buth have a noPublish field. If left to default, their use will cause an implicit publish()

Expression Level Transformation

Because the state of a constructor is rather delicate, the API prohibits you to just mess around with the whole constructor body at an expression level. For that to happen, you can register onGenerate hooks. These will be called when the corresponding ClassBuilder does its export. The hooks are cleared after the export.

TypeMap

You can find a type map, i.e. a map where the keys are haxe.macro.Type, in tink.macro.TypeMap. It's pretty much an ordinary map. Currently, it relies rather strongly on haxe.macro.TypeTools.toString() and it remains to be determined whether that is a reliable choice. Please report any issues you might face.