Search code examples
macroshaxe

Macro to fill out field names, values, and switch statements of a custom @:enum abstract?


I have a bit of a unique situation. For various reasons, chiefly interoperating with a nullable stringly typed legacy system, as well as various other needs I won't go into at the moment, I've settled on a custom @:enum abstract that looks like this:

@:enum abstract MyEnum(Null<Int>) {

    public var A = 0;
    public var B = 1;
    public var C = 2;
    public var D = 3;
    public var E = 4;
    public var F = 5;
    public var G = 6;

    @:from private static function fromString (value:String):MyEnum {

        return switch (value) {

            case "a": A;
            case "b": B;
            case "c": C;
            case "d": D;
            case "e": E;
            case "f": F;
            case "g": G;
            default: null;

        }

    }

    @:to private static function toString (value:Int):String {

        return switch (value) {

            case A: "a";
            case B: "b";
            case C: "c";
            case D: "d";
            case E: "e";
            case F: "f";
            case G: "g";
            default: null;

        }

    }

}

However, that's an annoyingly large amount of things to type, and when adding and removing members it's easy to make a manual error. Clearly, this follows a super predictable pattern and seems like a great thing to construct with a macro, but I am terrible at haxe macros.

Can someone explain how I could use a macro to build this enum in such a way that all I have to supply is a list of field names?

pseudocode:

@:enum abstract MyEnum = doTheMacroMagic(["A","B","C","D","E","F","G"]);

The logical steps would be:

  • Declare public vars from field names (upper case)
  • Declare fromString/toString values from field names (lower case)
  • Set public vars to 0-based integers and follow same order as field names are supplied

I think a simple practical example like this might make haxe macros finally "click" for me if I can see it in action.


Solution

  • Flixel handles a very similar use case in for classes like FlxKey with FlxMacroUtil.buildMap(). This expression macro looks for all uppercase, inline vars it finds in the abstract and generates a Map<String, EnumType> from it, with the keys being the field names and the values the field values (or the inverse of that if invert is true).

    @:enum
    abstract FlxKey(Int) from Int to Int
    {
        public static var fromStringMap(default, null):Map<String, FlxKey>
            = FlxMacroUtil.buildMap("flixel.input.keyboard.FlxKey");
    
        public static var toStringMap(default, null):Map<FlxKey, String>
            = FlxMacroUtil.buildMap("flixel.input.keyboard.FlxKey", true);
    
        var A              = 65;
        var B              = 66;
        // more keys...
    
        @:from
        public static inline function fromString(s:String)
        {
            s = s.toUpperCase();
            return fromStringMap.exists(s) ? fromStringMap.get(s) : NONE;
        }
    
        @:to
        public inline function toString():String
        {
            return toStringMap.get(this);
        }
    }
    

    I'd imagine that's a good starting point. If you want to generate the entire abstract, you will need a @:build macro.


    Answering the follow-up question, how to generate fields: This is actually quite straightforward with a build macro:

    @:enum
    @:build(Macro.createVariables(["A", "B", "C", "D", "E"]))
    abstract Generated(Int)
    {
    }
    

    Macro.hx (sensible to have in its own file to avoid having to deal with #if macro conditionals):

    package;
    
    import haxe.macro.Context;
    import haxe.macro.Expr;
    
    class Macro
    {
        public static macro function createVariables(varNames:Array<String>):Array<Field>
        {
            // get the current fields of the calling type (empty array in this case)
            var fields = Context.getBuildFields();
            for (i in 0...varNames.length)
                // create a custom variable and add it to the fields
                fields.push(createVariable(varNames[i], i));
            return fields;
        }
    
        private static function createVariable(name:String, value:Int):Field
        {
            return {
                name: name,
                doc: null,
                meta: [],
                access: [Access.APublic, Access.AStatic, Access.AInline],
                kind: FieldType.FVar(macro:Int, macro $v{value}),
                pos: Context.currentPos()
            }
        }
    }
    

    You'll notice the fields showing up in auto-completion for Generated.. You can also see what's been generated by looking at the Generated_Impl.dump when doing an AST dump.