Handlebars.Net: Fun with [Flags]

When working on the a project, the need arose for better handling of enums decorated with a [Flags] attribute. In a previous article we explored how to use Handlebars.NET to generate JSON strings. In this article we'll build further on that JSON generator to add support for enums. We will also move away from the static JsonHandlebarsDotNet to an injectable version.

Through this article I'll be working with the following enum:

[Flags]
enum MenuItems
{
    None = 0,
    Pizza = 1,
    Fries = 2,
    Pancakes = 4,
    Meatballs = 8,
    Pasta = 16,
    StuffWithP = Pizza | Pancakes | Pasta,
    All = Pizza | Fries | Pancakes | Meatballs | Pasta | StuffWithP
};

You can see that it has all the nice features a flagged enum comes with: multiple combinations and a None value. It is going to be fun; fun with flags!

I'm not the only one that enjoys flags. Source: Big Bang Theory, Warner Bros. Television

Deconstructing a [Flags] enum

When we want to deconstruct our enum to the unique flag values, we see some special use cases:

  • The None has value 0, so it can't be used with the HasFlag operation, as it will always return true. So MenuItems.Pasta.HasFlag(MenuItems.None) will return true. We will need to skip this value.
  • The StuffWithP and All values are combined values, so I want their individual values to appear in a deconstructed array.

The following method will deconstruct a flagged enum to a string array of unique values:

public static string[] DeconstructFlags(Enum items)
{
    if (items.GetType().GetCustomAttribute<FlagsAttribute>() == null)
    {
        throw new ArgumentException("Enum has no [Flags] attribute.", nameof(items));
    }

    // no value, no list
    var itemsValue = (int)(object)items;
    if (itemsValue == 0) return Array.Empty<string>();

    var result = new List<string>();

    foreach (var item in Enum.GetValues(items.GetType()))
    {
        if(item == null) continue;

        var value = (int)item;

        // skip combined flags
        if (!BitOperations.IsPow2(value))
        {
            continue;
        }

        if (items.HasFlag((Enum)item))
        {
            result.Add(item.ToString() ?? "");
        }
    }

    return result.ToArray();

}

This will return the following arrays:

void Echo(MenuItems items)
{
    Console.WriteLine($"{items}: {String.Join(" + ", DeconstructFlags(items))}");
}

Echo(MenuItems.None);
// outputs: None: None

Echo(MenuItems.Pasta);
// outputs: Pasta: Pasta

Echo(MenuItems.Fries | MenuItems.Pizza);
// outputs: Pizza, Fries: Pizza + Fries

Echo(MenuItems.StuffWithP);
// outputs: StuffWithP: Pizza + Pancakes + Pasta

Note how it is skipping the StuffWithP value and deconstructs properly.

Make Handlebars.NET respect the [Flags]

Now, we would love to iterate over our flagged enums using an {{#each }} statement. By default this is not possible and Handlebars.NET will just return the integer value of your enum. We'll make a FlaggedEnumObjectDescriptorProvider that fixes this:

var order = new
{
    items = MenuItems.Pizza | MenuItems.Pancakes
};

string source = @"
{ 
    ""order"": [
        {{#each items}}""{{this}}""{{#unless @last}},
        {{/unless}}{{/each}}
    ],
    ""orders"": ""{{items}}""
}";

var hbs = new JsonTemplateGenerator().Handlebars;
hbs.Configuration.ObjectDescriptorProviders.Add(new FlaggedEnumObjectDescriptorProvider());

var template = hbs.Compile(source);
var json = template(order);
Console.WriteLine(json);

This will output:

{
    "order": [
        "Pizza",
        "Pancakes"
    ],
    "orders": "Pizza, Pancakes"
}

The trick is to register a FlaggedEnumObjectDescriptorProvider that will handle the flagged enums.

Implementing an IObjectDescriptorProvider

I did some research on how to implement an IObjectDescriptorProvider and took the Handlebars.Net.Extension.Json array iterator as inspiration. The class checks if the type is an enum with the [Flags] attribute and returns an iterator that uses the DeconstructFlags method we've seen before.

using HandlebarsDotNet.Compiler;
using HandlebarsDotNet.Iterators;
using HandlebarsDotNet.ObjectDescriptors;
using HandlebarsDotNet.PathStructure;
using HandlebarsDotNet.Runtime;
using HandlebarsDotNet.ValueProviders;
using System.Numerics;
using System.Reflection;

public class FlaggedEnumObjectDescriptorProvider : IObjectDescriptorProvider
{
    public bool TryGetDescriptor(Type type, out ObjectDescriptor value)
    {
        if (!type.IsEnum || type.GetCustomAttribute<FlagsAttribute>() == null)
        {
            value = ObjectDescriptor.Empty;
            return false;
        }

        value = new ObjectDescriptor(
            type,
            null,
            null,
            self => new FlagEnumInterator()
        );

        return true;
    }

    public static string[] DeconstructFlags(Enum items)
    {
        if (items.GetType().GetCustomAttribute<FlagsAttribute>() == null)
        {
            throw new ArgumentException("Enum has no [Flags] attribute.", nameof(items));
        }

        // no value, no list
        var itemsValue = (int)(object)items;
        if (itemsValue == 0) return Array.Empty<string>();

        var result = new List<string>();

        foreach (var item in Enum.GetValues(items.GetType()))
        {
            if(item == null) continue;

            var value = (int)item;

            // skip combined flags
            if (!BitOperations.IsPow2(value))
            {
                continue;
            }

            if (items.HasFlag((Enum)item))
            {
                result.Add(item.ToString() ?? "");
            }
        }

        return result.ToArray();

    }

    internal class FlagEnumInterator : IIterator
    {
        public void Iterate(in HandlebarsDotNet.EncodedTextWriter writer, HandlebarsDotNet.BindingContext context, ChainSegment[] blockParamsVariables, object input, TemplateDelegate template, TemplateDelegate ifEmpty)
        {
            using var innerContext = context.CreateFrame();
            var iterator = new IteratorValues(innerContext);
            var blockParamsValues = new BlockParamsValues(innerContext, blockParamsVariables);

            blockParamsValues.CreateProperty(0, out var _0);
            blockParamsValues.CreateProperty(1, out var _1);

            iterator.First = BoxedValues.True;
            iterator.Last = BoxedValues.False;

            var target = DeconstructFlags((Enum)input);

            var count = target.Length;
            var enumerator = target.GetEnumerator();

            var index = 0;
            var lastIndex = count - 1;
            while (enumerator.MoveNext())
            {
                var value = enumerator.Current;
                var objectIndex = BoxedValues.Int(index);

                if (index == 1) iterator.First = BoxedValues.False;
                if (index == lastIndex) iterator.Last = BoxedValues.True;

                iterator.Index = objectIndex;

                object resolvedValue = value;

                blockParamsValues[_0] = resolvedValue;
                blockParamsValues[_1] = objectIndex;

                iterator.Value = resolvedValue;
                innerContext.Value = resolvedValue;

                template(writer, innerContext);

                ++index;
            }

            if (index == 0)
            {
                innerContext.Value = context.Value;
                ifEmpty(writer, innerContext);
            }

        }
    }
}

Notice how @first and @last are also implemented by this class.

Improve the JsonTemplateGenerator

We can now add the FlaggedEnumObjectDescriptorProvider to the ObjectDescriptorProviders of our JsonTemplateGenerator constructor:

public JsonTemplateGenerator()
{
    Handlebars = HandlebarsDotNet.Handlebars.Create();
    Handlebars.Configuration.TextEncoder = new JsonTextEncoder();
    Handlebars.Configuration.ObjectDescriptorProviders.Add(new FlaggedEnumObjectDescriptorProvider());
}

One might also consider adding the helpers to it: HandlebarsHelpers.Register(Handlebars);

Final thoughts

I just love Handlebars and the fact that the template engine is so versatile. It is super easy to handle enum flags.

Further reading

When writing this article, I stumbled upon some resources that might interest you:

Changelog

expand_less