Open sandboxFocusImprove this doc

Validating code from an aspect

Validating source code and providing meaningful error messages is a critical feature of most aspects. Failure to do so can result in confusing error messages for the aspect's user or even invalid behavior at runtime.

The first two techniques for validating code involve defining eligibility (see Defining the eligibility of aspects) and reporting errors from the BuildAspect method (see Reporting and suppressing diagnostics). In this article, we introduce two additional techniques:

  • Validating the code before applying any aspect, or after applying all aspects.
  • Validating references to the target declaration of the aspect.

Validating code before or after aspects

By default, the BuildAspect receives the version of the code model before applying the current aspect. However, there may be instances where you need to validate a different version of the code model. Metalama allows you to validate three versions:

  • Before the current aspect has been applied,
  • Before any aspect has been applied, or
  • After all aspects have been applied.

To validate a different version of the code model, follow these steps:

  1. Define one or more static fields of type DiagnosticDefinition as explained in Reporting and suppressing diagnostics.
  2. Create a method with the signature void Validate(in DeclarationValidationContext context). Implement the validation logic in this method. All the data you need is in the DeclarationValidationContext object. When you detect a rule violation, report a diagnostic as described in Reporting and suppressing diagnostics.
  3. Override or implement the BuildAspect method of your aspect. From this method:
    1. Access the builder.Outbound property,
    2. Call the AfterAllAspects() or BeforeAnyAspect() method to select the version of the code model,
    3. Select declarations to be validated using the SelectMany and Select methods,
    4. Call the ValidateReferences method and pass a delegate to the validation method.

Example: requiring a later aspect to be applied

The following example demonstrates how to validate that the target type of the Log aspect contains a field named _logger. The implementation allows the _logger field to be introduced after the Log aspect has been applied, thanks to a call to AfterAllAspects().

1using Doc.ValidateAfterAllAspects;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Diagnostics;
5using Metalama.Framework.Validation;
6using System.IO;
7using System.Linq;
8
9// Note that aspects are applied in inverse order than they appear in the next line.
10[assembly:
11    AspectOrder( AspectOrderDirection.RunTime, typeof(AddLoggerAttribute), typeof(LogAttribute) )]
12
13namespace Doc.ValidateAfterAllAspects;
14
15internal class LogAttribute : OverrideMethodAspect
16{
17    private static readonly DiagnosticDefinition<INamedType> _error = new(
18        "MY001",
19        Severity.Error,
20        "The type {0} must have a field named _logger." );
21
22    public override void BuildAspect( IAspectBuilder<IMethod> builder )
23    {
24        builder.Outbound.AfterAllAspects()
25            .Select( m => m.DeclaringType )
26            .Validate( this.ValidateDeclaringType );
27    }
28
29    private void ValidateDeclaringType( in DeclarationValidationContext context )
30    {
31        var type = (INamedType) context.Declaration;
32
33        if ( !type.AllFields.OfName( "_logger" ).Any() )
34        {
35            context.Diagnostics.Report( _error.WithArguments( type ) );
36        }
37    }
38
39    public override dynamic? OverrideMethod()
40    {
41        meta.This._logger.WriteLine( $"Executing {meta.Target.Method}." );
42
43        return meta.Proceed();
44    }
45}
46
47internal class AddLoggerAttribute : TypeAspect
48{
49    [Introduce]
50    private TextWriter _logger = File.CreateText( "log.txt" );
51}
1namespace Doc.ValidateAfterAllAspects;
2
3[AddLogger]
4internal class OkClass
5{
6    [Log]
7    private void Bar() { }
8}
9
Error MY001: The type ErrorClass must have a field named _logger.

10internal class ErrorClass
11{
12    [Log]
13    private void Bar() { }
14}

Validating code references

Aspects can validate not only the declaration to which they are applied but also how this target declaration is used. In other words, aspects can validate code references.

In order to optimize performance, Metalama tries to avoid validating every single code reference. Instead, it has a concept of validator granularity (ReferenceGranularity), which accepts the values Compilation, Namespace, Type, Member, or ParameterOrAttribute. When a validator is invariant within some level of granularity, then its predicate should only be evaluated once within the declaration at this level of granularity. For instance, if a validator granularity is set to Namespace, then all references within that namespace will be either valid or invalid at the same time. Therefore, Metalama will only make a single call for this whole namespace. The validator method will be prevented from accessing details of a finer granularity level than the declared one. Warnings will be reported on all code references.

To create an aspect that validates references:

  1. In the aspect class, define one or more static fields of type DiagnosticDefinition as explained in Reporting and suppressing diagnostics.
  2. Create a method of arbitrary name with the signature void ValidateReference( ReferenceValidationContext context ). Implement the validation logic in this method. All the data you need is in the ReferenceValidationContext object. When you detect a rule violation, report a diagnostic as described in Reporting and suppressing diagnostics. Alternatively, you can create a class implementing the InboundReferenceValidator abstract class.
  3. Override or implement the BuildAspect method of your aspect. From this method:
    1. Access the builder.Outbound property,
    2. Select declarations to be validated using the SelectMany and Select methods,
    3. Call the ValidateInboundReferences method. Pass a delegate to the validation method or an instance of the validator class and the ReferenceGranularity.
Note

The delegate passed to the ValidateInboundReferences method must point to a named method of the aspect.

Example: ForTestOnly, aspect implementation

The following example implements a custom attribute [ForTestOnly] that enforces that the target of this attribute can only be used from a namespace that ends with .Tests..

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Diagnostics;
4using Metalama.Framework.Validation;
5using System;
6
7namespace Doc.ForTestOnly;
8
9[AttributeUsage(
10    AttributeTargets.Class | AttributeTargets.Struct |
11    AttributeTargets.Method | AttributeTargets.Constructor | AttributeTargets.Property |
12    AttributeTargets.Event )]
13public class ForTestOnlyAttribute : Attribute, IAspect<IMember>
14{
15    private static readonly DiagnosticDefinition<IDeclaration> _warning = new(
16        "MY001",
17        Severity.Warning,
18        "'{0}' can only be invoked from a namespace that ends with Tests." );
19
20    public void BuildAspect( IAspectBuilder<IMember> builder )
21    {
22        builder.Outbound.ValidateInboundReferences(
23            this.ValidateReference,
24            ReferenceGranularity.Namespace );
25    }
26
27    private void ValidateReference( ReferenceValidationContext context )
28    {
29        if ( !context.Origin.Namespace.FullName.EndsWith( ".Tests", StringComparison.Ordinal ) )
30        {
31            context.Diagnostics.Report(
32                r => r.OriginDeclaration.IsContainedIn( context.Destination.Type )
33                    ? null
34                    : _warning.WithArguments( context.Destination.Namespace ) );
35        }
36    }
37}
1using System;
2
3namespace Doc.ForTestOnly
4{
5    public class MyService
6    {
7        // Normal constructor.
8        public MyService() : this( DateTime.Now ) { }
9
10        [ForTestOnly]
11        internal MyService( DateTime dateTime ) { }
12    }
13
14    internal class NormalClass
15    {
16        // Usage NOT allowed here because we are not in a Tests namespace.
        Warning MY001: 'Doc.ForTestOnly' can only be invoked from a namespace that ends with Tests.

17        private MyService _service = new( DateTime.Now.AddDays( 1 ) );
18    }
19
20    namespace Tests
21    {
22        internal class TestClass
23        {
24            // Usage allowed here because we are in a Tests namespace.
25            private MyService _service = new( DateTime.Now.AddDays( 2 ) );
26        }
27    }
28}