Skip to content

Commit 982debb

Browse files
authored
Merge pull request #449 from CommunityToolkit/dev/forwarded-property-attributes
Enable explicit attribute forwarding for [ObservableProperty]
2 parents e6257d8 + 213c876 commit 982debb

File tree

10 files changed

+420
-9
lines changed

10 files changed

+420
-9
lines changed

CommunityToolkit.Mvvm.SourceGenerators/CommunityToolkit.Mvvm.SourceGenerators.projitems

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@
5252
<Compile Include="$(MSBuildThisFileDirectory)ComponentModel\TransitiveMembersGenerator.Execute.cs" />
5353
<Compile Include="$(MSBuildThisFileDirectory)Diagnostics\Analyzers\FieldWithOrphanedDependentObservablePropertyAttributesAnalyzer.cs" />
5454
<Compile Include="$(MSBuildThisFileDirectory)Diagnostics\Analyzers\UnsupportedCSharpLanguageVersionAnalyzer.cs" />
55+
<Compile Include="$(MSBuildThisFileDirectory)Diagnostics\Suppressors\ObservablePropertyAttributeWithPropertyTargetDiagnosticSuppressor.cs" />
5556
<Compile Include="$(MSBuildThisFileDirectory)Diagnostics\DiagnosticDescriptors.cs" />
5657
<Compile Include="$(MSBuildThisFileDirectory)Diagnostics\DiagnosticExtensions.cs" />
58+
<Compile Include="$(MSBuildThisFileDirectory)Diagnostics\SuppressionDescriptors.cs" />
5759
<Compile Include="$(MSBuildThisFileDirectory)Extensions\AttributeDataExtensions.cs" />
5860
<Compile Include="$(MSBuildThisFileDirectory)Extensions\CompilationExtensions.cs" />
5961
<Compile Include="$(MSBuildThisFileDirectory)Extensions\HashCodeExtensions.cs" />

CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/AttributeInfo.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Collections.Generic;
77
using System.Collections.Immutable;
88
using System.Linq;
9+
using System.Threading;
910
using CommunityToolkit.Mvvm.SourceGenerators.Extensions;
1011
using CommunityToolkit.Mvvm.SourceGenerators.Helpers;
1112
using Microsoft.CodeAnalysis;
@@ -51,6 +52,49 @@ public static AttributeInfo From(AttributeData attributeData)
5152
namedArguments.ToImmutable());
5253
}
5354

55+
/// <summary>
56+
/// Creates a new <see cref="AttributeInfo"/> instance from a given syntax node.
57+
/// </summary>
58+
/// <param name="typeSymbol">The symbol for the attribute type.</param>
59+
/// <param name="semanticModel">The <see cref="SemanticModel"/> instance for the current run.</param>
60+
/// <param name="arguments">The sequence of <see cref="AttributeArgumentSyntax"/> instances to process.</param>
61+
/// <param name="token">The cancellation token for the current operation.</param>
62+
/// <returns>A <see cref="AttributeInfo"/> instance representing the input attribute data.</returns>
63+
public static AttributeInfo From(INamedTypeSymbol typeSymbol, SemanticModel semanticModel, IEnumerable<AttributeArgumentSyntax> arguments, CancellationToken token)
64+
{
65+
string typeName = typeSymbol.GetFullyQualifiedName();
66+
67+
ImmutableArray<TypedConstantInfo>.Builder constructorArguments = ImmutableArray.CreateBuilder<TypedConstantInfo>();
68+
ImmutableArray<(string, TypedConstantInfo)>.Builder namedArguments = ImmutableArray.CreateBuilder<(string, TypedConstantInfo)>();
69+
70+
foreach (AttributeArgumentSyntax argument in arguments)
71+
{
72+
// The attribute expression has to have an available operation to extract information from
73+
if (semanticModel.GetOperation(argument.Expression, token) is not IOperation operation)
74+
{
75+
continue;
76+
}
77+
78+
TypedConstantInfo argumentInfo = TypedConstantInfo.From(operation, semanticModel, argument.Expression, token);
79+
80+
// Try to get the identifier name if the current expression is a named argument expression. If it
81+
// isn't, then the expression is a normal attribute constructor argument, so no extra work is needed.
82+
if (argument.NameEquals is { Name.Identifier.ValueText: string argumentName })
83+
{
84+
namedArguments.Add((argumentName, argumentInfo));
85+
}
86+
else
87+
{
88+
constructorArguments.Add(argumentInfo);
89+
}
90+
}
91+
92+
return new(
93+
typeName,
94+
constructorArguments.ToImmutable(),
95+
namedArguments.ToImmutable());
96+
}
97+
5498
/// <summary>
5599
/// Gets an <see cref="AttributeSyntax"/> instance representing the current value.
56100
/// </summary>

CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/TypedConstantInfo.Factory.cs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
using System;
66
using System.Collections.Immutable;
77
using System.Linq;
8+
using System.Threading;
89
using Microsoft.CodeAnalysis;
10+
using Microsoft.CodeAnalysis.CSharp.Syntax;
11+
using Microsoft.CodeAnalysis.Operations;
912

1013
namespace CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models;
1114

@@ -57,4 +60,89 @@ public static TypedConstantInfo From(TypedConstant arg)
5760
_ => throw new ArgumentException("Invalid typed constant type"),
5861
};
5962
}
63+
64+
/// <summary>
65+
/// Creates a new <see cref="TypedConstantInfo"/> instance from a given <see cref="IOperation"/> value.
66+
/// </summary>
67+
/// <param name="operation">The input <see cref="IOperation"/> value.</param>
68+
/// <param name="semanticModel">The <see cref="SemanticModel"/> that was used to retrieve <paramref name="operation"/>.</param>
69+
/// <param name="expression">The <see cref="ExpressionSyntax"/> that <paramref name="operation"/> was retrieved from.</param>
70+
/// <param name="token">The cancellation token for the current operation.</param>
71+
/// <returns>A <see cref="TypedConstantInfo"/> instance representing <paramref name="operation"/>.</returns>
72+
/// <exception cref="ArgumentException">Thrown if the input argument is not valid.</exception>
73+
public static TypedConstantInfo From(
74+
IOperation operation,
75+
SemanticModel semanticModel,
76+
ExpressionSyntax expression,
77+
CancellationToken token)
78+
{
79+
if (operation.ConstantValue.HasValue)
80+
{
81+
// Enum values are constant but need to be checked explicitly in this case
82+
if (operation.Type?.TypeKind is TypeKind.Enum)
83+
{
84+
return new Enum(operation.Type!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), operation.ConstantValue.Value!);
85+
}
86+
87+
// Handle all other constant literals normally
88+
return operation.ConstantValue.Value switch
89+
{
90+
null => new Null(),
91+
string text => new Primitive.String(text),
92+
bool flag => new Primitive.Boolean(flag),
93+
byte b => new Primitive.Of<byte>(b),
94+
char c => new Primitive.Of<char>(c),
95+
double d => new Primitive.Of<double>(d),
96+
float f => new Primitive.Of<float>(f),
97+
int i => new Primitive.Of<int>(i),
98+
long l => new Primitive.Of<long>(l),
99+
sbyte sb => new Primitive.Of<sbyte>(sb),
100+
short sh => new Primitive.Of<short>(sh),
101+
uint ui => new Primitive.Of<uint>(ui),
102+
ulong ul => new Primitive.Of<ulong>(ul),
103+
ushort ush => new Primitive.Of<ushort>(ush),
104+
_ => throw new ArgumentException("Invalid primitive type")
105+
};
106+
}
107+
108+
if (operation is ITypeOfOperation typeOfOperation)
109+
{
110+
return new Type(typeOfOperation.TypeOperand.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
111+
}
112+
113+
if (operation is IArrayCreationOperation)
114+
{
115+
string? elementTypeName = ((IArrayTypeSymbol?)operation.Type)?.ElementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
116+
117+
// If the element type is not available (since the attribute wasn't checked), just default to object
118+
elementTypeName ??= "object";
119+
120+
InitializerExpressionSyntax? initializerExpression =
121+
(expression as ImplicitArrayCreationExpressionSyntax)?.Initializer
122+
?? (expression as ArrayCreationExpressionSyntax)?.Initializer;
123+
124+
// No initializer found, just return an empty array
125+
if (initializerExpression is null)
126+
{
127+
return new Array(elementTypeName, ImmutableArray<TypedConstantInfo>.Empty);
128+
}
129+
130+
ImmutableArray<TypedConstantInfo>.Builder items = ImmutableArray.CreateBuilder<TypedConstantInfo>(initializerExpression.Expressions.Count);
131+
132+
// Enumerate all array elements and extract serialized info for them
133+
foreach (ExpressionSyntax initializationExpression in initializerExpression.Expressions)
134+
{
135+
if (semanticModel.GetOperation(initializationExpression, token) is not IOperation initializationOperation)
136+
{
137+
throw new ArgumentException("Failed to retrieve an operation for the current array element");
138+
}
139+
140+
items.Add(From(initializationOperation, semanticModel, initializationExpression, token));
141+
}
142+
143+
return new Array(elementTypeName, items.MoveToImmutable());
144+
}
145+
146+
throw new ArgumentException("Invalid attribute argument value");
147+
}
60148
}

CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.Execute.cs

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
using System.Collections.Immutable;
66
using System.ComponentModel;
7+
using System.Diagnostics.CodeAnalysis;
78
using System.Globalization;
89
using System.Linq;
10+
using System.Threading;
911
using CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models;
1012
using CommunityToolkit.Mvvm.SourceGenerators.Diagnostics;
1113
using CommunityToolkit.Mvvm.SourceGenerators.Extensions;
@@ -28,10 +30,20 @@ internal static class Execute
2830
/// <summary>
2931
/// Processes a given field.
3032
/// </summary>
33+
/// <param name="fieldSyntax">The <see cref="FieldDeclarationSyntax"/> instance to process.</param>
3134
/// <param name="fieldSymbol">The input <see cref="IFieldSymbol"/> instance to process.</param>
35+
/// <param name="semanticModel">The <see cref="SemanticModel"/> instance for the current run.</param>
36+
/// <param name="token">The cancellation token for the current operation.</param>
37+
/// <param name="propertyInfo">The resulting <see cref="PropertyInfo"/> value, if successfully retrieved.</param>
3238
/// <param name="diagnostics">The resulting diagnostics from the processing operation.</param>
3339
/// <returns>The resulting <see cref="PropertyInfo"/> instance for <paramref name="fieldSymbol"/>, if successful.</returns>
34-
public static PropertyInfo? TryGetInfo(IFieldSymbol fieldSymbol, out ImmutableArray<Diagnostic> diagnostics)
40+
public static bool TryGetInfo(
41+
FieldDeclarationSyntax fieldSyntax,
42+
IFieldSymbol fieldSymbol,
43+
SemanticModel semanticModel,
44+
CancellationToken token,
45+
[NotNullWhen(true)] out PropertyInfo? propertyInfo,
46+
out ImmutableArray<Diagnostic> diagnostics)
3547
{
3648
ImmutableArray<Diagnostic>.Builder builder = ImmutableArray.CreateBuilder<Diagnostic>();
3749

@@ -44,9 +56,10 @@ internal static class Execute
4456
fieldSymbol.ContainingType,
4557
fieldSymbol.Name);
4658

59+
propertyInfo = null;
4760
diagnostics = builder.ToImmutable();
4861

49-
return null;
62+
return false;
5063
}
5164

5265
// Get the property type and name
@@ -63,12 +76,13 @@ internal static class Execute
6376
fieldSymbol.ContainingType,
6477
fieldSymbol.Name);
6578

79+
propertyInfo = null;
6680
diagnostics = builder.ToImmutable();
6781

6882
// If the generated property would collide, skip generating it entirely. This makes sure that
6983
// users only get the helpful diagnostic about the collision, and not the normal compiler error
7084
// about a definition for "Property" already existing on the target type, which might be confusing.
71-
return null;
85+
return false;
7286
}
7387

7488
// Check for special cases that are explicitly not allowed
@@ -80,9 +94,10 @@ internal static class Execute
8094
fieldSymbol.ContainingType,
8195
fieldSymbol.Name);
8296

97+
propertyInfo = null;
8398
diagnostics = builder.ToImmutable();
8499

85-
return null;
100+
return false;
86101
}
87102

88103
ImmutableArray<string>.Builder propertyChangedNames = ImmutableArray.CreateBuilder<string>();
@@ -168,6 +183,45 @@ internal static class Execute
168183
}
169184
}
170185

186+
// Gather explicit forwarded attributes info
187+
foreach (AttributeListSyntax attributeList in fieldSyntax.AttributeLists)
188+
{
189+
// Only look for attribute lists explicitly targeting the (generated) property. Roslyn will normally emit a
190+
// CS0657 warning (invalid target), but that is automatically suppressed by a dedicated diagnostic suppressor
191+
// that recognizes uses of this target specifically to support [ObservableProperty].
192+
if (attributeList.Target?.Identifier.Kind() is not SyntaxKind.PropertyKeyword)
193+
{
194+
continue;
195+
}
196+
197+
foreach (AttributeSyntax attribute in attributeList.Attributes)
198+
{
199+
// Roslyn ignores attributes in an attribute list with an invalid target, so we can't get the AttributeData as usual.
200+
// To reconstruct all necessary attribute info to generate the serialized model, we use the following steps:
201+
// - We try to get the attribute symbol from the semantic model, for the current attribute syntax. In case this is not
202+
// available (in theory it shouldn't, but it can be), we try to get it from the candidate symbols list for the node.
203+
// If there are no candidates or more than one, we just issue a diagnostic and stop processing the current attribute.
204+
// The returned symbols might be method symbols (constructor attribute) so in that case we can get the declaring type.
205+
// - We then go over each attribute argument expression and get the operation for it. This will still be available even
206+
// though the rest of the attribute is not validated nor bound at all. From the operation we can still retrieve all
207+
// constant values to build the AttributeInfo model. After all, attributes only support constant values, typeof(T)
208+
// expressions, or arrays of either these two types, or of other arrays with the same rules, recursively.
209+
// - From the syntax, we can also determine the identifier names for named attribute arguments, if any.
210+
// There is no need to validate anything here: the attribute will be forwarded as is, and then Roslyn will validate on the
211+
// generated property. Users will get the same validation they'd have had directly over the field. The only drawback is the
212+
// lack of IntelliSense when constructing attributes over the field, but this is the best we can do from this end anyway.
213+
SymbolInfo attributeSymbolInfo = semanticModel.GetSymbolInfo(attribute, token);
214+
215+
if ((attributeSymbolInfo.Symbol ?? attributeSymbolInfo.CandidateSymbols.SingleOrDefault()) is not ISymbol attributeSymbol ||
216+
(attributeSymbol as INamedTypeSymbol ?? attributeSymbol.ContainingType) is not INamedTypeSymbol attributeTypeSymbol)
217+
{
218+
continue;
219+
}
220+
221+
forwardedAttributes.Add(AttributeInfo.From(attributeTypeSymbol, semanticModel, attribute.ArgumentList?.Arguments ?? Enumerable.Empty<AttributeArgumentSyntax>(), token));
222+
}
223+
}
224+
171225
// Log the diagnostic for missing ObservableValidator, if needed
172226
if (hasAnyValidationAttributes &&
173227
!fieldSymbol.ContainingType.InheritsFromFullyQualifiedName("global::CommunityToolkit.Mvvm.ComponentModel.ObservableValidator"))
@@ -190,9 +244,7 @@ internal static class Execute
190244
fieldSymbol.Name);
191245
}
192246

193-
diagnostics = builder.ToImmutable();
194-
195-
return new(
247+
propertyInfo = new PropertyInfo(
196248
typeNameWithNullabilityAnnotations,
197249
fieldName,
198250
propertyName,
@@ -202,6 +254,10 @@ internal static class Execute
202254
notifyRecipients,
203255
notifyDataErrorInfo,
204256
forwardedAttributes.ToImmutable());
257+
258+
diagnostics = builder.ToImmutable();
259+
260+
return true;
205261
}
206262

207263
/// <summary>

CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
3737
return default;
3838
}
3939

40+
FieldDeclarationSyntax fieldDeclaration = (FieldDeclarationSyntax)context.TargetNode.Parent!.Parent!;
4041
IFieldSymbol fieldSymbol = (IFieldSymbol)context.TargetSymbol;
4142

42-
// Produce the incremental models
43+
// Get the hierarchy info for the target symbol, and try to gather the property info
4344
HierarchyInfo hierarchy = HierarchyInfo.From(fieldSymbol.ContainingType);
44-
PropertyInfo? propertyInfo = Execute.TryGetInfo(fieldSymbol, out ImmutableArray<Diagnostic> diagnostics);
45+
46+
_ = Execute.TryGetInfo(fieldDeclaration, fieldSymbol, context.SemanticModel, token, out PropertyInfo? propertyInfo, out ImmutableArray<Diagnostic> diagnostics);
4547

4648
return (Hierarchy: hierarchy, new Result<PropertyInfo?>(propertyInfo, diagnostics));
4749
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using Microsoft.CodeAnalysis;
2+
3+
namespace CommunityToolkit.Mvvm.SourceGenerators.Diagnostics;
4+
5+
/// <summary>
6+
/// A container for all <see cref="SuppressionDescriptors"/> instances for suppressed diagnostics by analyzers in this project.
7+
/// </summary>
8+
internal static class SuppressionDescriptors
9+
{
10+
/// <summary>
11+
/// Gets a <see cref="SuppressionDescriptor"/> for a field using [ObservableProperty] with on attribute list targeting a property.
12+
/// </summary>
13+
public static readonly SuppressionDescriptor PropertyAttributeListForObservablePropertyField = new(
14+
id: "MVVMTKSPR0001",
15+
suppressedDiagnosticId: "CS0657",
16+
justification: "Fields using [ObservableProperty] can use [property:] attribute lists to forward attributes to the generated properties");
17+
}

0 commit comments

Comments
 (0)