Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable explicit attribute forwarding for [ObservableProperty] #449

Merged
merged 6 commits into from
Sep 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@
<Compile Include="$(MSBuildThisFileDirectory)ComponentModel\TransitiveMembersGenerator.Execute.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Diagnostics\Analyzers\FieldWithOrphanedDependentObservablePropertyAttributesAnalyzer.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Diagnostics\Analyzers\UnsupportedCSharpLanguageVersionAnalyzer.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Diagnostics\Suppressors\ObservablePropertyAttributeWithPropertyTargetDiagnosticSuppressor.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Diagnostics\DiagnosticDescriptors.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Diagnostics\DiagnosticExtensions.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Diagnostics\SuppressionDescriptors.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Extensions\AttributeDataExtensions.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Extensions\CompilationExtensions.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Extensions\HashCodeExtensions.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using CommunityToolkit.Mvvm.SourceGenerators.Extensions;
using CommunityToolkit.Mvvm.SourceGenerators.Helpers;
using Microsoft.CodeAnalysis;
Expand Down Expand Up @@ -51,6 +52,49 @@ public static AttributeInfo From(AttributeData attributeData)
namedArguments.ToImmutable());
}

/// <summary>
/// Creates a new <see cref="AttributeInfo"/> instance from a given syntax node.
/// </summary>
/// <param name="typeSymbol">The symbol for the attribute type.</param>
/// <param name="semanticModel">The <see cref="SemanticModel"/> instance for the current run.</param>
/// <param name="arguments">The sequence of <see cref="AttributeArgumentSyntax"/> instances to process.</param>
/// <param name="token">The cancellation token for the current operation.</param>
/// <returns>A <see cref="AttributeInfo"/> instance representing the input attribute data.</returns>
public static AttributeInfo From(INamedTypeSymbol typeSymbol, SemanticModel semanticModel, IEnumerable<AttributeArgumentSyntax> arguments, CancellationToken token)
{
string typeName = typeSymbol.GetFullyQualifiedName();

ImmutableArray<TypedConstantInfo>.Builder constructorArguments = ImmutableArray.CreateBuilder<TypedConstantInfo>();
ImmutableArray<(string, TypedConstantInfo)>.Builder namedArguments = ImmutableArray.CreateBuilder<(string, TypedConstantInfo)>();

foreach (AttributeArgumentSyntax argument in arguments)
{
// The attribute expression has to have an available operation to extract information from
if (semanticModel.GetOperation(argument.Expression, token) is not IOperation operation)
{
continue;
}

TypedConstantInfo argumentInfo = TypedConstantInfo.From(operation, semanticModel, argument.Expression, token);

// Try to get the identifier name if the current expression is a named argument expression. If it
// isn't, then the expression is a normal attribute constructor argument, so no extra work is needed.
if (argument.NameEquals is { Name.Identifier.ValueText: string argumentName })
{
namedArguments.Add((argumentName, argumentInfo));
}
else
{
constructorArguments.Add(argumentInfo);
}
}

return new(
typeName,
constructorArguments.ToImmutable(),
namedArguments.ToImmutable());
}

/// <summary>
/// Gets an <see cref="AttributeSyntax"/> instance representing the current value.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Operations;

namespace CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models;

Expand Down Expand Up @@ -57,4 +60,89 @@ public static TypedConstantInfo From(TypedConstant arg)
_ => throw new ArgumentException("Invalid typed constant type"),
};
}

/// <summary>
/// Creates a new <see cref="TypedConstantInfo"/> instance from a given <see cref="IOperation"/> value.
/// </summary>
/// <param name="operation">The input <see cref="IOperation"/> value.</param>
/// <param name="semanticModel">The <see cref="SemanticModel"/> that was used to retrieve <paramref name="operation"/>.</param>
/// <param name="expression">The <see cref="ExpressionSyntax"/> that <paramref name="operation"/> was retrieved from.</param>
/// <param name="token">The cancellation token for the current operation.</param>
/// <returns>A <see cref="TypedConstantInfo"/> instance representing <paramref name="operation"/>.</returns>
/// <exception cref="ArgumentException">Thrown if the input argument is not valid.</exception>
public static TypedConstantInfo From(
IOperation operation,
SemanticModel semanticModel,
ExpressionSyntax expression,
CancellationToken token)
{
if (operation.ConstantValue.HasValue)
{
// Enum values are constant but need to be checked explicitly in this case
if (operation.Type?.TypeKind is TypeKind.Enum)
{
return new Enum(operation.Type!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), operation.ConstantValue.Value!);
}

// Handle all other constant literals normally
return operation.ConstantValue.Value switch
{
null => new Null(),
string text => new Primitive.String(text),
bool flag => new Primitive.Boolean(flag),
byte b => new Primitive.Of<byte>(b),
char c => new Primitive.Of<char>(c),
double d => new Primitive.Of<double>(d),
float f => new Primitive.Of<float>(f),
int i => new Primitive.Of<int>(i),
long l => new Primitive.Of<long>(l),
sbyte sb => new Primitive.Of<sbyte>(sb),
short sh => new Primitive.Of<short>(sh),
uint ui => new Primitive.Of<uint>(ui),
ulong ul => new Primitive.Of<ulong>(ul),
ushort ush => new Primitive.Of<ushort>(ush),
_ => throw new ArgumentException("Invalid primitive type")
};
}

if (operation is ITypeOfOperation typeOfOperation)
{
return new Type(typeOfOperation.TypeOperand.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
}

if (operation is IArrayCreationOperation)
{
string? elementTypeName = ((IArrayTypeSymbol?)operation.Type)?.ElementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);

// If the element type is not available (since the attribute wasn't checked), just default to object
elementTypeName ??= "object";

InitializerExpressionSyntax? initializerExpression =
(expression as ImplicitArrayCreationExpressionSyntax)?.Initializer
?? (expression as ArrayCreationExpressionSyntax)?.Initializer;

// No initializer found, just return an empty array
if (initializerExpression is null)
{
return new Array(elementTypeName, ImmutableArray<TypedConstantInfo>.Empty);
}

ImmutableArray<TypedConstantInfo>.Builder items = ImmutableArray.CreateBuilder<TypedConstantInfo>(initializerExpression.Expressions.Count);

// Enumerate all array elements and extract serialized info for them
foreach (ExpressionSyntax initializationExpression in initializerExpression.Expressions)
{
if (semanticModel.GetOperation(initializationExpression, token) is not IOperation initializationOperation)
{
throw new ArgumentException("Failed to retrieve an operation for the current array element");
}

items.Add(From(initializationOperation, semanticModel, initializationExpression, token));
}

return new Array(elementTypeName, items.MoveToImmutable());
}

throw new ArgumentException("Invalid attribute argument value");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

using System.Collections.Immutable;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Threading;
using CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models;
using CommunityToolkit.Mvvm.SourceGenerators.Diagnostics;
using CommunityToolkit.Mvvm.SourceGenerators.Extensions;
Expand All @@ -28,10 +30,20 @@ internal static class Execute
/// <summary>
/// Processes a given field.
/// </summary>
/// <param name="fieldSyntax">The <see cref="FieldDeclarationSyntax"/> instance to process.</param>
/// <param name="fieldSymbol">The input <see cref="IFieldSymbol"/> instance to process.</param>
/// <param name="semanticModel">The <see cref="SemanticModel"/> instance for the current run.</param>
/// <param name="token">The cancellation token for the current operation.</param>
/// <param name="propertyInfo">The resulting <see cref="PropertyInfo"/> value, if successfully retrieved.</param>
/// <param name="diagnostics">The resulting diagnostics from the processing operation.</param>
/// <returns>The resulting <see cref="PropertyInfo"/> instance for <paramref name="fieldSymbol"/>, if successful.</returns>
public static PropertyInfo? TryGetInfo(IFieldSymbol fieldSymbol, out ImmutableArray<Diagnostic> diagnostics)
public static bool TryGetInfo(
FieldDeclarationSyntax fieldSyntax,
IFieldSymbol fieldSymbol,
SemanticModel semanticModel,
CancellationToken token,
[NotNullWhen(true)] out PropertyInfo? propertyInfo,
out ImmutableArray<Diagnostic> diagnostics)
{
ImmutableArray<Diagnostic>.Builder builder = ImmutableArray.CreateBuilder<Diagnostic>();

Expand All @@ -44,9 +56,10 @@ internal static class Execute
fieldSymbol.ContainingType,
fieldSymbol.Name);

propertyInfo = null;
diagnostics = builder.ToImmutable();

return null;
return false;
}

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

propertyInfo = null;
diagnostics = builder.ToImmutable();

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

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

propertyInfo = null;
diagnostics = builder.ToImmutable();

return null;
return false;
}

ImmutableArray<string>.Builder propertyChangedNames = ImmutableArray.CreateBuilder<string>();
Expand Down Expand Up @@ -168,6 +183,45 @@ internal static class Execute
}
}

// Gather explicit forwarded attributes info
foreach (AttributeListSyntax attributeList in fieldSyntax.AttributeLists)
{
// Only look for attribute lists explicitly targeting the (generated) property. Roslyn will normally emit a
// CS0657 warning (invalid target), but that is automatically suppressed by a dedicated diagnostic suppressor
// that recognizes uses of this target specifically to support [ObservableProperty].
if (attributeList.Target?.Identifier.Kind() is not SyntaxKind.PropertyKeyword)
{
continue;
}

foreach (AttributeSyntax attribute in attributeList.Attributes)
{
// Roslyn ignores attributes in an attribute list with an invalid target, so we can't get the AttributeData as usual.
// To reconstruct all necessary attribute info to generate the serialized model, we use the following steps:
// - We try to get the attribute symbol from the semantic model, for the current attribute syntax. In case this is not
// available (in theory it shouldn't, but it can be), we try to get it from the candidate symbols list for the node.
// If there are no candidates or more than one, we just issue a diagnostic and stop processing the current attribute.
// The returned symbols might be method symbols (constructor attribute) so in that case we can get the declaring type.
// - We then go over each attribute argument expression and get the operation for it. This will still be available even
// though the rest of the attribute is not validated nor bound at all. From the operation we can still retrieve all
// constant values to build the AttributeInfo model. After all, attributes only support constant values, typeof(T)
// expressions, or arrays of either these two types, or of other arrays with the same rules, recursively.
// - From the syntax, we can also determine the identifier names for named attribute arguments, if any.
// There is no need to validate anything here: the attribute will be forwarded as is, and then Roslyn will validate on the
// generated property. Users will get the same validation they'd have had directly over the field. The only drawback is the
// lack of IntelliSense when constructing attributes over the field, but this is the best we can do from this end anyway.
SymbolInfo attributeSymbolInfo = semanticModel.GetSymbolInfo(attribute, token);

if ((attributeSymbolInfo.Symbol ?? attributeSymbolInfo.CandidateSymbols.SingleOrDefault()) is not ISymbol attributeSymbol ||
(attributeSymbol as INamedTypeSymbol ?? attributeSymbol.ContainingType) is not INamedTypeSymbol attributeTypeSymbol)
{
continue;
}

forwardedAttributes.Add(AttributeInfo.From(attributeTypeSymbol, semanticModel, attribute.ArgumentList?.Arguments ?? Enumerable.Empty<AttributeArgumentSyntax>(), token));
}
}

// Log the diagnostic for missing ObservableValidator, if needed
if (hasAnyValidationAttributes &&
!fieldSymbol.ContainingType.InheritsFromFullyQualifiedName("global::CommunityToolkit.Mvvm.ComponentModel.ObservableValidator"))
Expand All @@ -190,9 +244,7 @@ internal static class Execute
fieldSymbol.Name);
}

diagnostics = builder.ToImmutable();

return new(
propertyInfo = new PropertyInfo(
typeNameWithNullabilityAnnotations,
fieldName,
propertyName,
Expand All @@ -202,6 +254,10 @@ internal static class Execute
notifyRecipients,
notifyDataErrorInfo,
forwardedAttributes.ToImmutable());

diagnostics = builder.ToImmutable();

return true;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
return default;
}

FieldDeclarationSyntax fieldDeclaration = (FieldDeclarationSyntax)context.TargetNode.Parent!.Parent!;
IFieldSymbol fieldSymbol = (IFieldSymbol)context.TargetSymbol;

// Produce the incremental models
// Get the hierarchy info for the target symbol, and try to gather the property info
HierarchyInfo hierarchy = HierarchyInfo.From(fieldSymbol.ContainingType);
PropertyInfo? propertyInfo = Execute.TryGetInfo(fieldSymbol, out ImmutableArray<Diagnostic> diagnostics);

_ = Execute.TryGetInfo(fieldDeclaration, fieldSymbol, context.SemanticModel, token, out PropertyInfo? propertyInfo, out ImmutableArray<Diagnostic> diagnostics);

return (Hierarchy: hierarchy, new Result<PropertyInfo?>(propertyInfo, diagnostics));
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.CodeAnalysis;

namespace CommunityToolkit.Mvvm.SourceGenerators.Diagnostics;

/// <summary>
/// A container for all <see cref="SuppressionDescriptors"/> instances for suppressed diagnostics by analyzers in this project.
/// </summary>
internal static class SuppressionDescriptors
{
/// <summary>
/// Gets a <see cref="SuppressionDescriptor"/> for a field using [ObservableProperty] with on attribute list targeting a property.
/// </summary>
public static readonly SuppressionDescriptor PropertyAttributeListForObservablePropertyField = new(
id: "MVVMTKSPR0001",
suppressedDiagnosticId: "CS0657",
justification: "Fields using [ObservableProperty] can use [property:] attribute lists to forward attributes to the generated properties");
}
Loading