Open sandboxFocusImprove this doc

Exposing a configuration API

The Metalama.Framework.Options namespace is your primary resource when building a configuration API for your aspects. Although it may seem complex at first glance, it allows you to create a user-friendly configuration experience with relative ease.

The Metalama.Framework.Options namespace supports the following features:

  • Options are automatically inherited from the base type, base member, enclosing type, enclosing namespace, or project. This means you can easily configure options for the entire project, a specific namespace, type, or even a particular method.
  • Cross-project inheritance of options is fully supported and transparent.
  • You can build a developer-friendly programmatic API to configure your options, which can be used by your aspects' users from any fabric.
  • You can also build a custom configuration attribute.

Creating an option class

When designing a configuration API, the first step involves creating a class to represent and store the options. This option class must implement the IHierarchicalOptions<T> for as many declaration types as needed. For instance, if you're building a caching aspect, you would naturally implement the IHierarchicalOption<IMethod> interface because the aspect is applied to methods. However, to allow bulk configuration of methods, you would also implement IHierarchicalOption<INamedType> for types, IHierarchicalOption<INamespace> for namespaces, and IHierarchicalOption<ICompilation> for the entire project.

It's crucial to understand that option classes represent changes, and the classes themselves must be immutable. This means all properties must have an init accessor instead of a set accessor. Furthermore, most properties, including value-typed properties, must be nullable. Typically, a null value signifies the absence of a modification in this property. This is why we say option classes represent incremental objects and indirectly implement the IIncrementalObject interface.

The most important member of option classes is the ApplyChanges method. This method should merge two immutable incremental instances into one new immutable instance representing the combination of changes. Metalama calls this method to merge the configuration settings provided at different levels by the user.

The IHierarchicalOptions<T> interface defines a second method, GetDefaultOptions, which we should ignore at the moment and should just return null. This method is useful when getting default options from MSBuild properties. See Reading MSBuild properties for details.

In summary, to create an option class, follow these steps:

  1. Create a class (records are currently unsupported) that implements as many generic instances of the IHierarchicalOptions<T> interface as needed, where T is any type of declaration on which you want to allow the user to define options.
  2. Do not add any constructor to this class. The class must keep its default constructor.
  3. Ensure all properties are init-only and are of a nullable type, even value-typed ones (exceptions apply for complex types like collections, see below).
  4. Skip the implementation of IHierarchicalOptions.GetDefaultOptions(IProject) for now. Just return this.
  5. Implement the ApplyChanges method so that it returns a new instance of the option class combining the changes of the current instance and the supplied parameter, where the properties of the parameter win over the ones of the current instance.

Example: option class

The following class demonstrates a typical implementation of IHierarchicalOptions<T>. The object has no explicit constructor and has only init-only properties. The ApplyChanges method merges the changes and gives priority to the properties of the supplied parameter, if defined.

1using Metalama.Framework.Code;
2using Metalama.Framework.Options;
3using System.Diagnostics;
4
5namespace Doc.AspectConfiguration;
6
7// Options for the [Log] aspects.
8public class LoggingOptions : IHierarchicalOptions<IMethod>, IHierarchicalOptions<INamedType>,
9                              IHierarchicalOptions<INamespace>, IHierarchicalOptions<ICompilation>
10{
11    public string? Category { get; init; }
12
13    public TraceLevel? Level { get; init; }
14
15    object IIncrementalObject.ApplyChanges( object changes, in ApplyChangesContext context )
16    {
17        var other = (LoggingOptions) changes;
18
19        return new LoggingOptions
20        {
21            Category = other.Category ?? this.Category, Level = other.Level ?? this.Level
22        };
23    }
24}

Reading the options

Reading options that apply to a different context requires some care. There are two APIs. Both are exposed under the expression.

To read the options applying to any declaration, call the declaration.Enhancements() method and then GetOptions method.

Warning

Options provided by aspects through the IHierarchicalOptionsProvider interface (see below) are applied shortly before the aspect's BuildAspect method is executed. They will not be available to the d.Enhancements().GetOptions() before that moment.

Example: reading options

The following example demonstrates a template that uses d.Enhancements().GetOptions() to read the options applying to the current aspect.

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using System.Diagnostics;
4
5namespace Doc.AspectConfiguration;
6
7// The aspect itself, consuming the configuration.
8public class LogAttribute : OverrideMethodAspect
9{
10    public override dynamic? OverrideMethod()
11    {
12        var options = meta.Target.Method.Enhancements().GetOptions<LoggingOptions>();
13
14        var message = $"{options.Category}: Executing {meta.Target.Method}.";
15
16        switch ( options.Level!.Value )
17        {
18            case TraceLevel.Error:
19                Trace.TraceError( message );
20
21                break;
22
23            case TraceLevel.Info:
24                Trace.TraceInformation( message );
25
26                break;
27
28            case TraceLevel.Warning:
29                Trace.TraceWarning( message );
30
31                break;
32
33            case TraceLevel.Verbose:
34                Trace.WriteLine( message );
35
36                break;
37        }
38
39        return meta.Proceed();
40    }
41}

Configuring the options from a fabric

If you choose to make the option class public, the users of your aspects can now set the options using the amender.Outgoing.SetOptions method in any fabric. Users can also use methods like Select, SelectMany or Where.

Note

This technique not only works from fabrics, but also from any aspect that is applied before the aspect that will consume the option.

Example: applying options from a fabric

The following example puts all previous code snippets together. It adds a project fabric that configures the logging aspect for the whole project and then specify different options for a child namespace. You can check in the that the logging code generated for the target code refers to different categories, as configured by the fabrics.

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using System.Diagnostics;
4
5namespace Doc.AspectConfiguration;
6
7// The aspect itself, consuming the configuration.
8public class LogAttribute : OverrideMethodAspect
9{
10    public override dynamic? OverrideMethod()
11    {
12        var options = meta.Target.Method.Enhancements().GetOptions<LoggingOptions>();
13
14        var message = $"{options.Category}: Executing {meta.Target.Method}.";
15
16        switch ( options.Level!.Value )
17        {
18            case TraceLevel.Error:
19                Trace.TraceError( message );
20
21                break;
22
23            case TraceLevel.Info:
24                Trace.TraceInformation( message );
25
26                break;
27
28            case TraceLevel.Warning:
29                Trace.TraceWarning( message );
30
31                break;
32
33            case TraceLevel.Verbose:
34                Trace.WriteLine( message );
35
36                break;
37        }
38
39        return meta.Proceed();
40    }
41}
1using Metalama.Framework.Fabrics;
2using System.Diagnostics;
3using System.Linq;
4
5namespace Doc.AspectConfiguration;
6
7// The project fabric configures the project at compile time.
8public class Fabric : ProjectFabric
9{
10    public override void AmendProject( IProjectAmender amender )
11    {
12        amender.SetOptions(
13            new LoggingOptions { Category = "GeneralCategory", Level = TraceLevel.Info } );
14
15        amender
16            .Select(
17                x => x.GlobalNamespace.GetDescendant( "Doc.AspectConfiguration.ChildNamespace" )! )
18            .SetOptions( new LoggingOptions() { Category = "ChildCategory" } );
19
20        // Adds the aspect to all members.
21        amender
22            .SelectMany( c => c.Types.SelectMany( t => t.Methods ) )
23            .AddAspectIfEligible<LogAttribute>();
24    }
25}
1using Metalama.Framework.Code;
2using Metalama.Framework.Options;
3using System.Diagnostics;
4
5namespace Doc.AspectConfiguration;
6
7// Options for the [Log] aspects.
8public class LoggingOptions : IHierarchicalOptions<IMethod>, IHierarchicalOptions<INamedType>,
9                              IHierarchicalOptions<INamespace>, IHierarchicalOptions<ICompilation>
10{
11    public string? Category { get; init; }
12
13    public TraceLevel? Level { get; init; }
14
15    object IIncrementalObject.ApplyChanges( object changes, in ApplyChangesContext context )
16    {
17        var other = (LoggingOptions) changes;
18
19        return new LoggingOptions
20        {
21            Category = other.Category ?? this.Category, Level = other.Level ?? this.Level
22        };
23    }
24}
Source Code
1namespace Doc.AspectConfiguration
2{


3    // Some target code.
4    public class SomeClass
5    {
6        [Log]
7        public void SomeMethod() { }
8    }
9



10    namespace ChildNamespace
11    {
12        public class SomeOtherClass
13        {
14            [Log]
15            public void SomeMethod() { }
16        }
17    }



18}
Transformed Code
1using System.Diagnostics;
2
3namespace Doc.AspectConfiguration
4{
5    // Some target code.
6    public class SomeClass
7    {
8        [Log]
9        public void SomeMethod()
10        {
11            Trace.TraceInformation("GeneralCategory: Executing SomeClass.SomeMethod().");
12        }
13    }
14
15    namespace ChildNamespace
16    {
17        public class SomeOtherClass
18        {
19            [Log]
20            public void SomeMethod()
21            {
22                Trace.TraceInformation("ChildCategory: Executing SomeOtherClass.SomeMethod().");
23            }
24        }
25    }
26}

Exposing options directly on your aspect

Often, you will want to offer users of your aspect the possibility to specify options directly when instantiating the aspect, typically as properties of the aspect custom attribute.

To achieve this, your aspect must implement the IHierarchicalOptionsProvider interface. This interface has a single method GetOptions that returns a list of options objects, typically an instance of your option class wrapped into an array.

Note that custom attributes cannot have properties of nullable value types. Therefore, you cannot just duplicate the properties of the option class into your aspect. Instead, you have to create field-backed properties where every property is backed by a field of nullable field. With this design, the implementation of GetOptions becomes a mapping of the backing fields of the aspects to the properties of the option class.

Example: aspect providing options

In the following example, the aspect code has been updated to support configuration properties. Note how the Level property, of a value type, is backed by nullable value type, so we can distinguish between the default value and the unspecified value. The aspect shows a trivial implementation of the GetOptions aspect.

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Options;
4using System.Collections.Generic;
5using System.Diagnostics;
6
7namespace Doc.AspectConfiguration_Provider;
8
9// The aspect itself, consuming the configuration.
10public class LogAttribute : OverrideMethodAspect, IHierarchicalOptionsProvider
11{
12    private readonly TraceLevel? _level;
13
14    public string? Category { get; init; }
15
16    public TraceLevel Level
17    {
18        get => this._level ?? TraceLevel.Verbose;
19        init => this._level = value;
20    }
21
22    public IEnumerable<IHierarchicalOptions> GetOptions( in OptionsProviderContext context )
23        => new[] { new LoggingOptions { Category = this.Category, Level = this._level } };
24
25    public override dynamic? OverrideMethod()
26    {
27        var options = meta.Target.Method.Enhancements().GetOptions<LoggingOptions>();
28
29        var message = $"{options.Category}: Executing {meta.Target.Method}.";
30
31        switch ( options.Level ?? TraceLevel.Verbose )
32        {
33            case TraceLevel.Error:
34                Trace.TraceError( message );
35
36                break;
37
38            case TraceLevel.Info:
39                Trace.TraceInformation( message );
40
41                break;
42
43            case TraceLevel.Warning:
44                Trace.TraceWarning( message );
45
46                break;
47
48            case TraceLevel.Verbose:
49                Trace.WriteLine( message );
50
51                break;
52        }
53
54        return meta.Proceed();
55    }
56}

Creating a configuration custom attribute

In addition to the programmatic API represented by the option class, you may want to provide your users with configuration custom attributes. This would allow users to configure your aspect without resorting to a fabric.

To create a configuration custom attribute, follow these steps:

  1. Create a class derived from System.Attribute.
  2. Add the [AttributeUsage] attribute as required. Typically, you will want to allow users to apply this attribute to the assembly and the enclosing type.
  3. Implement the IHierarchicalOptionsProvider interface as described above.