Open sandboxFocusImprove this doc

Clone example, step 3: adding coding guidance

So far, we have built a powerful aspect that implements the Deep Clone pattern and has three pieces of API: the [Cloneable] and [Child] attributes and the method void CloneMembers(T). Our aspect already reports errors in unsupported cases. We will now see how we can improve the productivity of the aspect's users by providing coding guidance.

First, we would like to save users from the need to remember the name and signature of the void CloneMembers(T) method. When there is no such method in their code, we would like to add an action to the refactoring menu that would create this action like this:

Refactoring suggestion: add CloneMembers

Secondly, suppose that we have deployed the Cloneable aspect to the team, and we notice that developers frequently forget to annotate cloneable fields with the [Child] attribute, causing inconsistencies in the resulting cloned object tree. Such inconsistencies are tedious to debug because they may appear randomly after the cloning process, losing much time for the team and degrading trust in aspect-oriented programming and architecture decisions. As the aspect's authors, it is our job to prevent the most frequent pitfalls by reporting a warning and suggesting remediations.

To make sure that developers do not forget to annotate properties with the [Child] attribute, we will define a new attribute [Reference] and require developers to annotate any cloneable property with either [Child] or [Reference]. Otherwise, we will report a warning and suggest two code fixes: add [Child] or add [Reference] to the field. Thanks to this strategy, we ensure that developers no longer forget to classify properties and instead make a conscious choice.

The first thing developers will experience is the warning:

Refactoring suggestion: warning

Note the link Show potential fixes. If developers click on that link or hit Alt+Enter or Ctrl+., they will see two code suggestions:

Refactoring suggestion: fixes

Let's see how we can add these features to our aspect.

Aspect implementation

Here is the complete and updated aspect:

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.CodeFixes;
4using Metalama.Framework.Diagnostics;
5using Metalama.Framework.Project;
6
7[Inheritable]
8[EditorExperience( SuggestAsLiveTemplate = true )]
9public class CloneableAttribute : TypeAspect
10{
11    private static readonly DiagnosticDefinition<(DeclarationKind, IFieldOrProperty)>
12        _fieldOrPropertyCannotBeReadOnly =
13            new(
14                "CLONE01",
15                Severity.Error,
16                "The {0} '{1}' cannot be read-only because it is marked as a [Child]." );
17
18    private static readonly DiagnosticDefinition<(DeclarationKind, IFieldOrProperty, IType)>
19        _missingCloneMethod =
20            new(
21                "CLONE02",
22                Severity.Error,
23                "The {0} '{1}' cannot be a [Child] because its type '{2}' does not have a 'Clone' parameterless method." );
24
25    private static readonly DiagnosticDefinition<IMethod> _cloneMethodMustBePublic =
26        new(
27            "CLONE03",
28            Severity.Error,
29            "The '{0}' method must be public or internal." );
30
31    private static readonly DiagnosticDefinition<IProperty> _childPropertyMustBeAutomatic =
32        new(
33            "CLONE04",
34            Severity.Error,
35            "The property '{0}' cannot be a [Child] because is not an automatic property." );
36
37    private static readonly DiagnosticDefinition<(DeclarationKind, IFieldOrProperty)>
38        _annotateFieldOrProperty =
39            new( "CLONE05", Severity.Warning, "Mark the {0} '{1}' as a [Child] or [Reference]." );
40
41    public override void BuildAspect( IAspectBuilder<INamedType> builder )
42    {
43        // Verify child fields and properties.
44        if ( !this.VerifyFieldsAndProperties( builder ) )
45        {
46            builder.SkipAspect();
47
48            return;
49        }
50
51        // Introduce the Clone method.
52        builder.Advice.IntroduceMethod(
53            builder.Target,
54            nameof(this.CloneImpl),
55            whenExists: OverrideStrategy.Override,
56            args: new { T = builder.Target },
57            buildMethod: m =>
58            {
59                m.Name = "Clone";
60                m.ReturnType = builder.Target;
61            } );
62
63        // 
64        builder.Advice.IntroduceMethod(
65            builder.Target,
66            nameof(this.CloneMembers),
67            whenExists: OverrideStrategy.Override,
68            args: new { T = builder.Target } );
69
70        // 
71
72        // Implement the ICloneable interface.
73        builder.Advice.ImplementInterface(
74            builder.Target,
75            typeof(ICloneable),
76            OverrideStrategy.Ignore );
77
78        // When we have non-child fields or properties of a cloneable type,
79        // suggest to add the child attribute
80        var eligibleChildren = builder.Target.FieldsAndProperties
81            .Where(
82                f => f.Writeability == Writeability.All &&
83                     !f.IsImplicitlyDeclared &&
84                     !f.Attributes.OfAttributeType( typeof(ChildAttribute) ).Any() &&
85                     !f.Attributes.OfAttributeType( typeof(ReferenceAttribute) ).Any() &&
86                     f.Type is INamedType fieldType &&
87                     (fieldType.AllMethods.OfName( "Clone" )
88                          .Any( m => m.Parameters.Count == 0 ) ||
89                      fieldType.Attributes.OfAttributeType( typeof(CloneableAttribute) )
90                          .Any()) );
91
92        // 
93        foreach ( var fieldOrProperty in eligibleChildren )
94        {
95            builder.Diagnostics.Report(
96                _annotateFieldOrProperty
97                    .WithArguments( (fieldOrProperty.DeclarationKind, fieldOrProperty) )
98                    .WithCodeFixes(
99                        CodeFixFactory.AddAttribute(
100                            fieldOrProperty,
101                            typeof(ChildAttribute),
102                            "Cloneable | Mark as child" ),
103                        CodeFixFactory.AddAttribute(
104                            fieldOrProperty,
105                            typeof(ReferenceAttribute),
106                            "Cloneable | Mark as reference" ) ),
107                fieldOrProperty );
108        }
109
110        // 
111
112        // 
113        // If we don't have a CloneMember method, suggest to add it.
114        if ( !builder.Target.Methods.OfName( nameof(this.CloneMembers) ).Any() )
115        {
116            builder.Diagnostics.Suggest(
117                new CodeFix(
118                    "Cloneable | Customize manually",
119                    codeFix =>
120                        codeFix.ApplyAspectAsync(
121                            builder.Target,
122                            new AddEmptyCloneMembersAspect() ) ) );
123        }
124
125        // 
126    }
127
128    private bool VerifyFieldsAndProperties( IAspectBuilder<INamedType> builder )
129    {
130        var success = true;
131
132        // Verify that child fields are valid.
133        foreach ( var fieldOrProperty in GetCloneableFieldsOrProperties( builder.Target ) )
134        {
135            // The field or property must be writable.
136            if ( fieldOrProperty.Writeability != Writeability.All )
137            {
138                builder.Diagnostics.Report(
139                    _fieldOrPropertyCannotBeReadOnly.WithArguments(
140                        (
141                            fieldOrProperty.DeclarationKind,
142                            fieldOrProperty) ),
143                    fieldOrProperty );
144
145                success = false;
146            }
147
148            // If it is a field, it must be an automatic property.
149            if ( fieldOrProperty is IProperty { IsAutoPropertyOrField: false } property )
150            {
151                builder.Diagnostics.Report(
152                    _childPropertyMustBeAutomatic.WithArguments( property ),
153                    property );
154
155                success = false;
156            }
157
158            // The type of the field must be cloneable.
159            void ReportMissingMethod()
160            {
161                builder.Diagnostics.Report(
162                    _missingCloneMethod.WithArguments(
163                        (fieldOrProperty.DeclarationKind,
164                         fieldOrProperty,
165                         fieldOrProperty.Type) ),
166                    fieldOrProperty );
167            }
168
169            if ( fieldOrProperty.Type is not INamedType fieldType )
170            {
171                // The field type is an array, a pointer or another special type, which do not have a Clone method.
172                ReportMissingMethod();
173                success = false;
174            }
175            else
176            {
177                var cloneMethod = fieldType.AllMethods.OfName( "Clone" )
178                    .SingleOrDefault( p => p.Parameters.Count == 0 );
179
180                if ( cloneMethod == null )
181                {
182                    // There is no Clone method.
183                    // It may be implemented by an aspect, but we don't have access to aspects on other types
184                    // at design time.
185                    if ( !MetalamaExecutionContext.Current.ExecutionScenario.IsDesignTime )
186                    {
187                        if ( !fieldType.BelongsToCurrentProject ||
188                             !fieldType.Enhancements().HasAspect<CloneableAttribute>() )
189                        {
190                            ReportMissingMethod();
191                            success = false;
192                        }
193                    }
194                }
195                else if ( cloneMethod.Accessibility is not (Accessibility.Public
196                         or Accessibility.Internal) )
197                {
198                    // If we have a Clone method, it must be public.
199                    builder.Diagnostics.Report(
200                        _cloneMethodMustBePublic.WithArguments( cloneMethod ),
201                        fieldOrProperty );
202
203                    success = false;
204                }
205            }
206        }
207
208        return success;
209    }
210
211    private static IEnumerable<IFieldOrProperty> GetCloneableFieldsOrProperties( INamedType type )
212        => type.FieldsAndProperties.Where(
213            f =>
214                f.Attributes.OfAttributeType( typeof(ChildAttribute) ).Any() );
215
216    [Template]
217    public virtual T CloneImpl<[CompileTime] T>()
218    {
219        // This compile-time variable will receive the expression representing the base call.
220        // If we have a public Clone method, we will use it (this is the chaining pattern). Otherwise,
221        // we will call MemberwiseClone (this is the initialization of the pattern).
222        IExpression baseCall;
223
224        if ( meta.Target.Method.IsOverride )
225        {
226            baseCall = (IExpression) meta.Base.Clone();
227        }
228        else
229        {
230            baseCall = (IExpression) meta.This.MemberwiseClone();
231        }
232
233        // Define a local variable of the same type as the target type.
234        var clone = (T) baseCall.Value!;
235
236        // Call CloneMembers, which may have a handwritten part.
237        meta.This.CloneMembers( clone );
238
239        return clone;
240    }
241
242    [Template]
243    private void CloneMembers<[CompileTime] T>( T clone )
244    {
245        // Select cloneable fields.
246        var cloneableFields = GetCloneableFieldsOrProperties( meta.Target.Type );
247
248        foreach ( var field in cloneableFields )
249        {
250            // Check if we have a public method 'Clone()' for the type of the field.
251            var fieldType = (INamedType) field.Type;
252
253            field.With( clone ).Value = meta.Cast( fieldType, field.Value?.Clone() );
254        }
255
256        // Call the handwritten implementation, if any.
257        meta.Proceed();
258    }
259
260    [InterfaceMember( IsExplicit = true )]
261    private object Clone() => meta.This.Clone();
262}

We will first explain the implementation of the second requirement.

Adding warnings with two code fixes

As usual, we first need to define the error as a static field of the class:

37private static readonly DiagnosticDefinition<(DeclarationKind, IFieldOrProperty)>
38    _annotateFieldOrProperty =
39        new( "CLONE05", Severity.Warning, "Mark the {0} '{1}' as a [Child] or [Reference]." );
40

Then, we detect unannotated properties of a cloneable type. And report the warnings with suggestions for code fixes:

93foreach ( var fieldOrProperty in eligibleChildren )
94{
95    builder.Diagnostics.Report(
96        _annotateFieldOrProperty
97            .WithArguments( (fieldOrProperty.DeclarationKind, fieldOrProperty) )
98            .WithCodeFixes(
99                CodeFixFactory.AddAttribute(
100                    fieldOrProperty,
101                    typeof(ChildAttribute),
102                    "Cloneable | Mark as child" ),
103                CodeFixFactory.AddAttribute(
104                    fieldOrProperty,
105                    typeof(ReferenceAttribute),
106                    "Cloneable | Mark as reference" ) ),
107        fieldOrProperty );
108}
109

Notice that we used the WithCodeFixes method to attach code fixes to the diagnostics. To create the code fixes, we use the CodeFixFactory.AddAttribute method. The CodeFixFactory class contains other methods to create simple code fixes.

Suggesting CloneMembers

When we detect that a cloneable type does not already have a CloneMembers method, we suggest adding it without reporting a warning using the Suggest method:

113// If we don't have a CloneMember method, suggest to add it.
114if ( !builder.Target.Methods.OfName( nameof(this.CloneMembers) ).Any() )
115{
116    builder.Diagnostics.Suggest(
117        new CodeFix(
118            "Cloneable | Customize manually",
119            codeFix =>
120                codeFix.ApplyAspectAsync(
121                    builder.Target,
122                    new AddEmptyCloneMembersAspect() ) ) );
123}
124

Unlike adding attributes, there is no ready-made code fix from the CodeFixFactory class to implement this method. We must implement the code transformation ourselves and provide an instance of the CodeFix class. This object comprises just two elements: the title of the code fix and a delegate performing the code transformation thanks to an ICodeActionBuilder. The list of transformations that are directly available from the ICodeActionBuilder is limited, but we can get enormous power using the ApplyAspectAsync method, which can apply any aspect to any declaration.

To implement the code fix, we create the ad-hoc aspect class AddEmptyCloneMembersAspect, whose implementation should now be familiar:

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3
4internal class AddEmptyCloneMembersAspect : IAspect<INamedType>
5{
6    public void BuildAspect( IAspectBuilder<INamedType> builder )
7        => builder.Advice.IntroduceMethod(
8            builder.Target,
9            nameof(this.CloneMembers),
10            whenExists: OverrideStrategy.Override,
11            args: new { T = builder.Target } );
12
13    [Template]
14    private void CloneMembers<[CompileTime] T>( T clone )
15    {
16        meta.InsertComment( "Use this method to modify the 'clone' parameter." );
17        meta.InsertComment( "Your code executes after the aspect." );
18    }
19}

Note that we did not derive AddEmptyCloneMembersAspect from TypeAspect because it would make the aspect a custom attribute. Instead, we directly implemented the IAspect interface.

Summary

We implemented coding guidance into our Cloneable aspect so that our users do not have to look at the design documentation so often and to prevent them from making frequent mistakes. We used two new techniques: attaching code fixes to warnings using the IDiagnostic.WithCodeFixes method and suggesting code fixes without warning using the Suggest method.