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:
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:
Note the link Show potential fixes. If developers click on that link or hit Alt+Enter
or Ctrl+.
, they will see two code suggestions:
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)> _fieldOrPropertyCannotBeReadOnly =
12 new("CLONE01", Severity.Error, "The {0} '{1}' cannot be read-only because it is marked as a [Child].");
13
14 private static readonly DiagnosticDefinition<(DeclarationKind, IFieldOrProperty, IType)> _missingCloneMethod =
15 new("CLONE02", Severity.Error,
16 "The {0} '{1}' cannot be a [Child] because its type '{2}' does not have a 'Clone' parameterless method.");
17
18 private static readonly DiagnosticDefinition<IProperty> _childPropertyMustBeAutomatic =
19 new("CLONE03", Severity.Error, "The property '{0}' cannot be a [Child] because is not an automatic property.");
20
21 private static readonly DiagnosticDefinition<(DeclarationKind, IFieldOrProperty)> _annotateFieldOrProperty =
22 new("CLONE04", Severity.Warning, "Mark the {0} '{1}' as a [Child] or [Reference].");
23
24
25 public override void BuildAspect( IAspectBuilder<INamedType> builder )
26 {
27 // Verify that child fields are valid.
28 var hasError = false;
29 foreach ( var fieldOrProperty in GetClonableFieldsOrProperties( builder.Target ) )
30 {
31 // The field or property must be writable.
32 if ( fieldOrProperty.Writeability != Writeability.All )
33 {
34 builder.Diagnostics.Report(
35 _fieldOrPropertyCannotBeReadOnly.WithArguments( (fieldOrProperty.DeclarationKind,
36 fieldOrProperty) ), fieldOrProperty );
37 hasError = true;
38 }
39
40 // If it is a field, it must be an automatic property.
41 if ( fieldOrProperty is IProperty property && property.IsAutoPropertyOrField == false )
42 {
43 builder.Diagnostics.Report( _childPropertyMustBeAutomatic.WithArguments( property ), property );
44 hasError = true;
45 }
46
47 // The type of the field must be cloneable.
48 if ( !MetalamaExecutionContext.Current.ExecutionScenario.IsDesignTime )
49 {
50 var fieldType = fieldOrProperty.Type as INamedType;
51
52 if ( fieldType == null ||
53 !(fieldType.AllMethods.OfName( "Clone" ).Where( p => p.Parameters.Count == 0 ).Any() ||
54 (fieldType.BelongsToCurrentProject &&
55 fieldType.Enhancements().HasAspect<CloneableAttribute>())) )
56 {
57 builder.Diagnostics.Report(
58 _missingCloneMethod.WithArguments( (fieldOrProperty.DeclarationKind, fieldOrProperty,
59 fieldOrProperty.Type) ), fieldOrProperty );
60 hasError = true;
61 }
62 }
63 }
64
65 // Stop here if we have errors.
66 if ( hasError )
67 {
68 builder.SkipAspect();
69 return;
70 }
71
72 // Introduce the Clone method.
73 builder.Advice.IntroduceMethod(
74 builder.Target,
75 nameof(this.CloneImpl),
76 whenExists: OverrideStrategy.Override,
77 args: new { T = builder.Target },
78 buildMethod: m =>
79 {
80 m.Name = "Clone";
81 m.ReturnType = builder.Target;
82 } );
83 builder.Advice.IntroduceMethod(
84 builder.Target,
85 nameof(this.CloneMembers),
86 whenExists: OverrideStrategy.Override,
87 args: new { T = builder.Target } );
88
89 // Implement the ICloneable interface.
90 builder.Advice.ImplementInterface(
91 builder.Target,
92 typeof(ICloneable),
93 OverrideStrategy.Ignore );
94
95 // When we have non-child fields or properties of a cloneable type,
96 // suggest to add the child attribute
97 var eligibleChildren = builder.Target.FieldsAndProperties
98 .Where( f => f.Writeability == Writeability.All &&
99 !f.IsImplicitlyDeclared &&
100 !f.Attributes.OfAttributeType( typeof(ChildAttribute) ).Any() &&
101 !f.Attributes.OfAttributeType( typeof(ReferenceAttribute) ).Any() &&
102 f.Type is INamedType fieldType &&
103 (fieldType.AllMethods.OfName( "Clone" ).Where( m => m.Parameters.Count == 0 ).Any() ||
104 fieldType.Attributes.OfAttributeType( typeof(CloneableAttribute) ).Any()) );
105
106
107 foreach ( var fieldOrProperty in eligibleChildren )
108 {
109 builder.Diagnostics.Report( _annotateFieldOrProperty
110 .WithArguments( (fieldOrProperty.DeclarationKind, fieldOrProperty) ).WithCodeFixes(
111 CodeFixFactory.AddAttribute( fieldOrProperty, typeof(ChildAttribute), "Cloneable | Mark as child" ),
112 CodeFixFactory.AddAttribute( fieldOrProperty, typeof(ReferenceAttribute),
113 "Cloneable | Mark as reference" ) ), fieldOrProperty );
114 }
115
116 // If we don't have a CloneMember method, suggest to add it.
117 if ( !builder.Target.Methods.OfName( nameof(this.CloneMembers) ).Any() )
118 {
119 builder.Diagnostics.Suggest(
120 new CodeFix( "Cloneable | Customize manually",
121 codeFix => codeFix.ApplyAspectAsync( builder.Target, new AddEmptyCloneMembersAspect() ) ) );
122 }
123 }
124
125 private static IEnumerable<IFieldOrProperty> GetClonableFieldsOrProperties( INamedType type )
126 => type.FieldsAndProperties.Where( f => f.Attributes.OfAttributeType( typeof(ChildAttribute) ).Any() );
127
128 [Template]
129 public virtual T CloneImpl<[CompileTime] T>()
130 {
131 // This compile-time variable will receive the expression representing the base call.
132 // If we have a public Clone method, we will use it (this is the chaining pattern). Otherwise,
133 // we will call MemberwiseClone (this is the initialization of the pattern).
134 IExpression baseCall;
135
136 if ( meta.Target.Method.IsOverride )
137 {
138 baseCall = (IExpression) meta.Base.Clone();
139 }
140 else
141 {
142 baseCall = (IExpression) meta.This.MemberwiseClone();
143 }
144
145 // Define a local variable of the same type as the target type.
146 var clone = (T) baseCall.Value!;
147
148 // Call CloneMembers, which may have a hand-written part.
149 meta.This.CloneMembers( clone );
150
151
152 return clone;
153 }
154
155 [Template]
156 private void CloneMembers<[CompileTime] T>( T clone )
157 {
158 // Select clonable fields.
159 var clonableFields = GetClonableFieldsOrProperties( meta.Target.Type );
160
161 foreach ( var field in clonableFields )
162 {
163 // Check if we have a public method 'Clone()' for the type of the field.
164 var fieldType = (INamedType) field.Type;
165
166 field.With( clone ).Value = meta.Cast( fieldType, field.Value?.Clone() );
167 }
168
169 // Call the hand-written implementation, if any.
170 meta.Proceed();
171 }
172
173 [InterfaceMember( IsExplicit = true )]
174 private object Clone() => meta.This.Clone();
175}
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:
21 private static readonly DiagnosticDefinition<(DeclarationKind, IFieldOrProperty)> _annotateFieldOrProperty =
22 new("CLONE04", Severity.Warning, "Mark the {0} '{1}' as a [Child] or [Reference].");
Then, we detect unannotated properties of a cloneable type. And report the warnings with suggestions for code fixes:
107 foreach ( var fieldOrProperty in eligibleChildren )
108 {
109 builder.Diagnostics.Report( _annotateFieldOrProperty
110 .WithArguments( (fieldOrProperty.DeclarationKind, fieldOrProperty) ).WithCodeFixes(
111 CodeFixFactory.AddAttribute( fieldOrProperty, typeof(ChildAttribute), "Cloneable | Mark as child" ),
112 CodeFixFactory.AddAttribute( fieldOrProperty, typeof(ReferenceAttribute),
113 "Cloneable | Mark as reference" ) ), fieldOrProperty );
114 }
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:
117 if ( !builder.Target.Methods.OfName( nameof(this.CloneMembers) ).Any() )
118 {
119 builder.Diagnostics.Suggest(
120 new CodeFix( "Cloneable | Customize manually",
121 codeFix => codeFix.ApplyAspectAsync( builder.Target, new AddEmptyCloneMembersAspect() ) ) );
122 }
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.