9 changed files with 850 additions and 6 deletions
@ -0,0 +1,17 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
|
||||
|
<PropertyGroup> |
||||
|
<TargetFramework>netstandard2.0</TargetFramework> |
||||
|
<Nullable>enable</Nullable> |
||||
|
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> |
||||
|
</PropertyGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4"> |
||||
|
<PrivateAssets>all</PrivateAssets> |
||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> |
||||
|
</PackageReference> |
||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.4.0" /> |
||||
|
</ItemGroup> |
||||
|
|
||||
|
</Project> |
||||
@ -0,0 +1,555 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Concurrent; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Collections.Immutable; |
||||
|
using System.Linq; |
||||
|
using System.Text.RegularExpressions; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.CodeAnalysis; |
||||
|
using Microsoft.CodeAnalysis.CSharp; |
||||
|
using Microsoft.CodeAnalysis.Diagnostics; |
||||
|
using Microsoft.CodeAnalysis.Operations; |
||||
|
|
||||
|
namespace Avalonia.Analyzers; |
||||
|
|
||||
|
public partial class AvaloniaPropertyAnalyzer |
||||
|
{ |
||||
|
public class CompileAnalyzer |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// A dictionary that maps field/property symbols to the AvaloniaProperty objects assigned to them.
|
||||
|
/// </summary>
|
||||
|
private readonly ConcurrentDictionary<ISymbol, AvaloniaPropertyDescription> _avaloniaProperyDescriptions = new(SymbolEqualityComparer.Default); |
||||
|
|
||||
|
private readonly ConcurrentDictionary<IPropertySymbol, ImmutableArray<AvaloniaPropertyDescription>> _clrPropertyToAvaloniaProperties = new(SymbolEqualityComparer.Default); |
||||
|
|
||||
|
private readonly INamedTypeSymbol _avaloniaObjectType; |
||||
|
private readonly ImmutableHashSet<IMethodSymbol> _getValueMethods; |
||||
|
private readonly ImmutableHashSet<IMethodSymbol> _setValueMethods; |
||||
|
private readonly INamedTypeSymbol _avaloniaPropertyType; |
||||
|
private readonly INamedTypeSymbol _styledPropertyType; |
||||
|
private readonly INamedTypeSymbol _attachedPropertyType; |
||||
|
private readonly INamedTypeSymbol _directPropertyType; |
||||
|
private readonly ImmutableHashSet<IMethodSymbol> _avaloniaPropertyRegisterMethods; |
||||
|
private readonly ImmutableHashSet<IMethodSymbol> _avaloniaPropertyAddOwnerMethods; |
||||
|
|
||||
|
public CompileAnalyzer(CompilationStartAnalysisContext context) |
||||
|
{ |
||||
|
_avaloniaObjectType = GetTypeOrThrow("Avalonia.AvaloniaObject"); |
||||
|
_getValueMethods = _avaloniaObjectType.GetMembers("GetValue").OfType<IMethodSymbol>().ToImmutableHashSet<IMethodSymbol>(SymbolEqualityComparer.Default); |
||||
|
_setValueMethods = _avaloniaObjectType.GetMembers("SetValue").OfType<IMethodSymbol>().ToImmutableHashSet<IMethodSymbol>(SymbolEqualityComparer.Default); |
||||
|
|
||||
|
_avaloniaPropertyType = GetTypeOrThrow("Avalonia.AvaloniaProperty"); |
||||
|
_styledPropertyType = GetTypeOrThrow("Avalonia.StyledProperty`1"); |
||||
|
_attachedPropertyType = GetTypeOrThrow("Avalonia.AttachedProperty`1"); |
||||
|
_directPropertyType = GetTypeOrThrow("Avalonia.DirectProperty`2"); |
||||
|
|
||||
|
_avaloniaPropertyRegisterMethods = _avaloniaPropertyType.GetMembers() |
||||
|
.OfType<IMethodSymbol>().Where(m => m.Name.StartsWith("Register")).ToImmutableHashSet<IMethodSymbol>(SymbolEqualityComparer.Default); |
||||
|
|
||||
|
_avaloniaPropertyAddOwnerMethods = new[] { _styledPropertyType, _attachedPropertyType, _directPropertyType } |
||||
|
.SelectMany(t => t.GetMembers("AddOwner").OfType<IMethodSymbol>()).ToImmutableHashSet<IMethodSymbol>(SymbolEqualityComparer.Default); |
||||
|
|
||||
|
FindAvaloniaPropertySymbols(context.Compilation, context.CancellationToken); |
||||
|
|
||||
|
context.RegisterOperationAction(AnalyzeFieldInitializer, OperationKind.FieldInitializer); |
||||
|
context.RegisterOperationAction(AnalyzePropertyInitializer, OperationKind.PropertyInitializer); |
||||
|
|
||||
|
context.RegisterSymbolStartAction(StartPropertySymbolAnalysis, SymbolKind.Property); |
||||
|
|
||||
|
if (context.Compilation.Language == LanguageNames.CSharp) |
||||
|
{ |
||||
|
context.RegisterCodeBlockAction(AnalyzePropertyMethods); |
||||
|
} |
||||
|
|
||||
|
INamedTypeSymbol GetTypeOrThrow(string name) => context.Compilation.GetTypeByMetadataName(name) ?? throw new KeyNotFoundException($"Could not locate {name} in the compilation context."); |
||||
|
} |
||||
|
|
||||
|
private void FindAvaloniaPropertySymbols(Compilation compilation, CancellationToken cancellationToken) |
||||
|
{ |
||||
|
var namespaceStack = new Stack<INamespaceSymbol>(); |
||||
|
namespaceStack.Push(compilation.GlobalNamespace); |
||||
|
|
||||
|
var types = new List<INamedTypeSymbol>(); |
||||
|
|
||||
|
while (namespaceStack.Count > 0) |
||||
|
{ |
||||
|
var current = namespaceStack.Pop(); |
||||
|
|
||||
|
foreach (var type in current.GetTypeMembers()) |
||||
|
{ |
||||
|
if (DerivesFrom(type, _avaloniaObjectType)) |
||||
|
{ |
||||
|
types.Add(type); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
foreach (var child in current.GetNamespaceMembers()) |
||||
|
{ |
||||
|
namespaceStack.Push(child); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
var references = new ConcurrentBag<(ISymbol symbol, Func<ISymbolInitializerOperation, IEnumerable<ISymbol>> getInits)>(); |
||||
|
|
||||
|
var parallelOptions = new ParallelOptions() { CancellationToken = cancellationToken }; |
||||
|
|
||||
|
Parallel.ForEach(types, parallelOptions, type => |
||||
|
{ |
||||
|
foreach (var member in type.GetMembers()) |
||||
|
{ |
||||
|
switch (member) |
||||
|
{ |
||||
|
case IFieldSymbol fieldSymbol when IsValidAvaloniaPropertyStorage(fieldSymbol): |
||||
|
references.Add((fieldSymbol, so => ((IFieldInitializerOperation)so).InitializedFields)); |
||||
|
break; |
||||
|
case IPropertySymbol propertySymbol when IsValidAvaloniaPropertyStorage(propertySymbol): |
||||
|
references.Add((propertySymbol, so => ((IPropertyInitializerOperation)so).InitializedProperties)); |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// key initializes value
|
||||
|
var fieldInitializations = new ConcurrentDictionary<ISymbol, ISymbol>(SymbolEqualityComparer.Default); |
||||
|
|
||||
|
Parallel.ForEach(references, parallelOptions, tuple => |
||||
|
{ |
||||
|
foreach (var syntaxRef in tuple.symbol.DeclaringSyntaxReferences) |
||||
|
{ |
||||
|
var node = syntaxRef.GetSyntax(cancellationToken); |
||||
|
if (!compilation.ContainsSyntaxTree(node.SyntaxTree)) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
var model = compilation.GetSemanticModel(node.SyntaxTree); |
||||
|
var operation = node.ChildNodes().Select(n => model.GetOperation(n, cancellationToken)).OfType<ISymbolInitializerOperation>().FirstOrDefault(); |
||||
|
|
||||
|
if (operation == null) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var operationValue = operation.Value; |
||||
|
|
||||
|
while (operationValue is IConversionOperation conversion) |
||||
|
{ |
||||
|
operationValue = conversion.Operand; |
||||
|
} |
||||
|
|
||||
|
switch (operationValue) |
||||
|
{ |
||||
|
case IInvocationOperation invocation: |
||||
|
RegisterInitializer_Invocation(tuple.getInits(operation), invocation, tuple.symbol); |
||||
|
break; |
||||
|
case IFieldReferenceOperation fieldRef when IsValidAvaloniaPropertyStorage(fieldRef.Field): |
||||
|
fieldInitializations[fieldRef.Field] = tuple.symbol; |
||||
|
break; |
||||
|
case IPropertyReferenceOperation propRef when IsValidAvaloniaPropertyStorage(propRef.Property): |
||||
|
fieldInitializations[propRef.Property] = tuple.symbol; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// we have recorded every Register and AddOwner call. Now follow assignment chains.
|
||||
|
foreach (var root in fieldInitializations.Keys.Intersect(_avaloniaProperyDescriptions.Keys, SymbolEqualityComparer.Default).ToArray()) |
||||
|
{ |
||||
|
var propertyDescription = _avaloniaProperyDescriptions[root]; |
||||
|
var owner = propertyDescription.AssignedTo[root]; |
||||
|
|
||||
|
var current = root; |
||||
|
do |
||||
|
{ |
||||
|
var target = fieldInitializations[current]; |
||||
|
|
||||
|
propertyDescription.AssignedTo[target] = owner; // This loop handles simple assignment operations, so do NOT change the owner
|
||||
|
_avaloniaProperyDescriptions[target] = propertyDescription; |
||||
|
|
||||
|
fieldInitializations.TryGetValue(target, out current); |
||||
|
} |
||||
|
while(current != null); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void RegisterInitializer_Invocation(IEnumerable<ISymbol> initializedSymbols, IInvocationOperation invocation, ISymbol target) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
if (invocation.TargetMethod.ReturnType is not INamedTypeSymbol propertyType) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (_avaloniaPropertyRegisterMethods.Contains(invocation.TargetMethod.OriginalDefinition)) // This is a call to one of the AvaloniaProperty.Register* methods
|
||||
|
{ |
||||
|
if (!invocation.TargetMethod.IsGenericMethod) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var typeParamLookup = invocation.TargetMethod.TypeParameters.Select((s, i) => (param: s, index: i)) |
||||
|
.ToDictionary(t => t.param.Name, t => (INamedTypeSymbol)invocation.TargetMethod.TypeArguments[t.index]); |
||||
|
|
||||
|
if (!typeParamLookup.TryGetValue("TOwner", out var ownerType) && // if it's NOT a generic parameter, try to work out the runtime value
|
||||
|
invocation.TargetMethod.Parameters.FirstOrDefault(p => p.Name == "ownerType") is INamedTypeSymbol ownerTypeParam && |
||||
|
invocation.Arguments.FirstOrDefault(a => SymbolEquals(a.Parameter, ownerTypeParam)) is IArgumentOperation argument) |
||||
|
{ |
||||
|
switch (argument.Value) |
||||
|
{ |
||||
|
case ITypeOfOperation typeOf: |
||||
|
ownerType = (INamedTypeSymbol)typeOf.Type!; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (ownerType == null || !typeParamLookup.TryGetValue("TValue", out var propertyValueType)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
foreach (var symbol in initializedSymbols) |
||||
|
{ |
||||
|
string name; |
||||
|
switch (invocation.Arguments[0].Value) |
||||
|
{ |
||||
|
case INameOfOperation nameof when nameof.Argument is IPropertyReferenceOperation propertyReference: |
||||
|
name = propertyReference.Property.Name; |
||||
|
break; |
||||
|
case IAssignmentOperation assignment when assignment.ConstantValue is { HasValue: true } stringLiteral: |
||||
|
name = (string)stringLiteral.Value!; |
||||
|
break; |
||||
|
default: |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var description = _avaloniaProperyDescriptions.GetOrAdd(symbol, s => new AvaloniaPropertyDescription(name, propertyType, propertyValueType)); |
||||
|
description.Name = name; |
||||
|
description.AssignedTo[symbol] = ownerType; |
||||
|
description.OwnerTypes.Add(ownerType); |
||||
|
} |
||||
|
} |
||||
|
else if (_avaloniaPropertyAddOwnerMethods.Contains(invocation.TargetMethod.OriginalDefinition)) // This is a call to one of the AddOwner methods
|
||||
|
{ |
||||
|
if (invocation.TargetMethod.TypeArguments[0] is not INamedTypeSymbol ownerType) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
ISymbol sourceSymbol; |
||||
|
switch (invocation.Instance) |
||||
|
{ |
||||
|
case IFieldReferenceOperation fieldReference: |
||||
|
sourceSymbol = fieldReference.Field; |
||||
|
break; |
||||
|
case IPropertyReferenceOperation propertyReference: |
||||
|
sourceSymbol = propertyReference.Property; |
||||
|
break; |
||||
|
default: |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var propertyValueType = AvaloniaPropertyType_GetValueType(propertyType); |
||||
|
|
||||
|
foreach (var symbol in initializedSymbols) |
||||
|
{ |
||||
|
var description = _avaloniaProperyDescriptions.GetOrAdd(symbol, s => |
||||
|
{ |
||||
|
string inferredName = target.Name; |
||||
|
|
||||
|
var match = Regex.Match(target.Name, "(?<name>.*)Property$"); |
||||
|
if (match.Success) |
||||
|
{ |
||||
|
inferredName = match.Groups["name"].Value; |
||||
|
} |
||||
|
return new AvaloniaPropertyDescription(inferredName, (INamedTypeSymbol)invocation.TargetMethod.ReturnType, propertyValueType); |
||||
|
}); |
||||
|
|
||||
|
description.AssignedTo[symbol] = ownerType; |
||||
|
description.OwnerTypes.Add(ownerType); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
throw new AvaloniaAnalysisException($"Failed to register the initializer of '{target}'.", ex); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void AnalyzeFieldInitializer(OperationAnalysisContext context) |
||||
|
{ |
||||
|
var operation = (IFieldInitializerOperation)context.Operation; |
||||
|
|
||||
|
foreach (var field in operation.InitializedFields) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
if (!_avaloniaProperyDescriptions.TryGetValue(field, out var description)) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
if (!IsValidAvaloniaPropertyStorage(field)) |
||||
|
{ |
||||
|
context.ReportDiagnostic(Diagnostic.Create(InappropriatePropertyAssignment, field.Locations[0], field)); |
||||
|
} |
||||
|
|
||||
|
AnalyzeInitializer_Shared(context, field, description); |
||||
|
|
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
throw new AvaloniaAnalysisException($"Failed to process initialization of field '{field}'.", ex); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void AnalyzePropertyInitializer(OperationAnalysisContext context) |
||||
|
{ |
||||
|
var operation = (IPropertyInitializerOperation)context.Operation; |
||||
|
|
||||
|
foreach (var property in operation.InitializedProperties) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
if (!_avaloniaProperyDescriptions.TryGetValue(property, out var description)) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
if (!IsValidAvaloniaPropertyStorage(property)) |
||||
|
{ |
||||
|
context.ReportDiagnostic(Diagnostic.Create(InappropriatePropertyAssignment, property.Locations[0], property)); |
||||
|
} |
||||
|
|
||||
|
AnalyzeInitializer_Shared(context, property, description); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
throw new AvaloniaAnalysisException($"Failed to process initialization of property '{property}'.", ex); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void AnalyzeInitializer_Shared(OperationAnalysisContext context, ISymbol assignmentSymbol, AvaloniaPropertyDescription description) |
||||
|
{ |
||||
|
if (!assignmentSymbol.Name.Contains(description.Name)) |
||||
|
{ |
||||
|
context.ReportDiagnostic(Diagnostic.Create(PropertyNameMismatch, assignmentSymbol.Locations[0], |
||||
|
description.Name, assignmentSymbol)); |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
var ownerType = description.AssignedTo[assignmentSymbol]; |
||||
|
|
||||
|
if (!IsAvaloniaPropertyType(description.PropertyType, _attachedPropertyType) && !SymbolEquals(ownerType, assignmentSymbol.ContainingType)) |
||||
|
{ |
||||
|
context.ReportDiagnostic(Diagnostic.Create(OwnerDoesNotMatchOuterType, assignmentSymbol.Locations[0], ownerType)); |
||||
|
} |
||||
|
} |
||||
|
catch (KeyNotFoundException) |
||||
|
{ |
||||
|
return; // WIP
|
||||
|
throw new KeyNotFoundException($"Assignment operation for {assignmentSymbol} was not recorded."); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void StartPropertySymbolAnalysis(SymbolStartAnalysisContext context) |
||||
|
{ |
||||
|
var property = (IPropertySymbol)context.Symbol; |
||||
|
try |
||||
|
{ |
||||
|
var avaloniaPropertyDescriptions = GetAvaloniaPropertiesForType(property.ContainingType).ToLookup(d => d.Name); |
||||
|
|
||||
|
var candidateTargetProperties = avaloniaPropertyDescriptions[property.Name].ToImmutableArray(); |
||||
|
|
||||
|
switch (candidateTargetProperties.Length) |
||||
|
{ |
||||
|
case 0: |
||||
|
return; // does not refer to an AvaloniaProperty
|
||||
|
case 1: |
||||
|
candidateTargetProperties[0].PropertyWrappers.Add(property); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
_clrPropertyToAvaloniaProperties[property] = candidateTargetProperties; |
||||
|
|
||||
|
context.RegisterSymbolEndAction(context => |
||||
|
{ |
||||
|
if (candidateTargetProperties.Length > 1) |
||||
|
{ |
||||
|
var candidateSymbols = candidateTargetProperties.Select(d => d.ClosestAssignmentFor(property.ContainingType)).Where(s => s != null); |
||||
|
context.ReportDiagnostic(Diagnostic.Create(AmbiguousPropertyName, property.Locations[0], candidateSymbols.SelectMany(s => s!.Locations), |
||||
|
property.ContainingType, property.Name, $"\n\t{string.Join("\n\t", candidateSymbols)}")); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var avaloniaPropertyDescription = candidateTargetProperties[0]; |
||||
|
var avaloniaPropertyStorage = avaloniaPropertyDescription.ClosestAssignmentFor(property.ContainingType); |
||||
|
|
||||
|
if (avaloniaPropertyStorage == null) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
context.ReportDiagnostic(Diagnostic.Create(AssociatedAvaloniaProperty, property.Locations[0], new[] { avaloniaPropertyStorage.Locations[0] }, |
||||
|
avaloniaPropertyDescription.PropertyType.Name, avaloniaPropertyStorage)); |
||||
|
|
||||
|
if (!SymbolEquals(property.Type, avaloniaPropertyDescription.ValueType, includeNullability: true)) |
||||
|
{ |
||||
|
context.ReportDiagnostic(Diagnostic.Create(PropertyTypeMismatch, property.Locations[0], |
||||
|
avaloniaPropertyStorage, $"\t\n{string.Join("\t\n", avaloniaPropertyDescription.ValueType, property.Type)}")); |
||||
|
} |
||||
|
|
||||
|
if (property.DeclaredAccessibility != avaloniaPropertyStorage.DeclaredAccessibility) |
||||
|
{ |
||||
|
context.ReportDiagnostic(Diagnostic.Create(InconsistentAccessibility, property.Locations[0], "property", avaloniaPropertyStorage)); |
||||
|
} |
||||
|
|
||||
|
VerifyAccessor(property.GetMethod, "readable", "get"); |
||||
|
|
||||
|
if (!IsAvaloniaPropertyType(avaloniaPropertyDescription.PropertyType, _directPropertyType)) |
||||
|
{ |
||||
|
VerifyAccessor(property.SetMethod, "writeable", "set"); |
||||
|
} |
||||
|
|
||||
|
void VerifyAccessor(IMethodSymbol? method, string verb, string methodName) |
||||
|
{ |
||||
|
if (method == null) |
||||
|
{ |
||||
|
context.ReportDiagnostic(Diagnostic.Create(MissingAccessor, property.Locations[0], avaloniaPropertyStorage, verb, methodName)); |
||||
|
} |
||||
|
else if (method.DeclaredAccessibility != avaloniaPropertyStorage.DeclaredAccessibility && method.DeclaredAccessibility != property.DeclaredAccessibility) |
||||
|
{ |
||||
|
context.ReportDiagnostic(Diagnostic.Create(InconsistentAccessibility, method.Locations[0], "property accessor", avaloniaPropertyStorage)); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
throw new AvaloniaAnalysisException($"Failed to analyse property '{property}'.", ex); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void AnalyzePropertyMethods(CodeBlockAnalysisContext context) |
||||
|
{ |
||||
|
if (context.OwningSymbol is not IMethodSymbol { AssociatedSymbol: IPropertySymbol property } method) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
if (!_clrPropertyToAvaloniaProperties.TryGetValue(property, out var candidateTargetProperties) || |
||||
|
candidateTargetProperties.Length != 1) // a diagnostic about multiple candidates will have already been reported
|
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var avaloniaPropertyDescription = candidateTargetProperties.Single(); |
||||
|
|
||||
|
if (IsAvaloniaPropertyType(avaloniaPropertyDescription.PropertyType, _directPropertyType)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (!SymbolEquals(property.Type, avaloniaPropertyDescription.ValueType)) |
||||
|
{ |
||||
|
return; // a diagnostic about this will have already been reported, and if the cast is implicit then this message would be confusing anyway
|
||||
|
} |
||||
|
|
||||
|
var bodyNode = context.CodeBlock.ChildNodes().Single(); |
||||
|
|
||||
|
var operation = bodyNode.DescendantNodes() |
||||
|
.Where(n => n.IsKind(SyntaxKind.InvocationExpression)) // this line is specific to C#
|
||||
|
.Select(n => (IInvocationOperation)context.SemanticModel.GetOperation(n)!) |
||||
|
.FirstOrDefault(); |
||||
|
|
||||
|
var isGetMethod = method.MethodKind == MethodKind.PropertyGet; |
||||
|
|
||||
|
var expectedInvocations = isGetMethod ? _getValueMethods : _setValueMethods; |
||||
|
|
||||
|
if (operation == null || bodyNode.ChildNodes().Count() != 1 || !expectedInvocations.Contains(operation.TargetMethod.OriginalDefinition)) |
||||
|
{ |
||||
|
ReportSideEffects(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (operation.Arguments.Length != 0) |
||||
|
{ |
||||
|
var argumentValue = operation.Arguments[0].Value; |
||||
|
if (argumentValue is IConversionOperation conversion) |
||||
|
{ |
||||
|
argumentValue = conversion.Operand; |
||||
|
} |
||||
|
|
||||
|
switch (argumentValue) |
||||
|
{ |
||||
|
case IFieldReferenceOperation fieldRef when avaloniaPropertyDescription.AssignedTo.ContainsKey(fieldRef.Field): |
||||
|
case IPropertyReferenceOperation propertyRef when avaloniaPropertyDescription.AssignedTo.ContainsKey(propertyRef.Property): |
||||
|
break; // the argument is a reference to the correct AvaloniaProperty object
|
||||
|
default: |
||||
|
ReportSideEffects(argumentValue.Syntax.GetLocation()); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (!isGetMethod && |
||||
|
operation.Arguments.Length >= 2 && |
||||
|
operation.Arguments[1].Value.Kind != OperationKind.ParameterReference) // passing something other than `value` to SetValue
|
||||
|
{ |
||||
|
ReportSideEffects(operation.Arguments[1].Syntax.GetLocation()); |
||||
|
} |
||||
|
|
||||
|
void ReportSideEffects(Location? locationOverride = null) |
||||
|
{ |
||||
|
var propertySourceName = avaloniaPropertyDescription.ClosestAssignmentFor(method.ContainingType)?.Name ?? "[unknown]"; |
||||
|
|
||||
|
context.ReportDiagnostic(Diagnostic.Create(AccessorSideEffects, locationOverride ?? context.CodeBlock.GetLocation(), |
||||
|
avaloniaPropertyDescription.Name, |
||||
|
isGetMethod ? "read" : "written to", |
||||
|
isGetMethod ? "get" : "set", |
||||
|
isGetMethod ? $"GetValue({propertySourceName})" : $"SetValue({propertySourceName}, value)")); |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
throw new AvaloniaAnalysisException($"Failed to process property accessor '{method}'.", ex); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private INamedTypeSymbol AvaloniaPropertyType_GetValueType(INamedTypeSymbol type) |
||||
|
{ |
||||
|
var compareType = type.IsGenericType ? type.ConstructUnboundGenericType().OriginalDefinition : type; |
||||
|
|
||||
|
if (SymbolEquals(compareType, _styledPropertyType) || SymbolEquals(compareType, _attachedPropertyType)) |
||||
|
{ |
||||
|
return (INamedTypeSymbol)type.TypeArguments[0]; |
||||
|
} |
||||
|
else if (SymbolEquals(compareType, _directPropertyType)) |
||||
|
{ |
||||
|
return (INamedTypeSymbol)type.TypeArguments[1]; |
||||
|
} |
||||
|
|
||||
|
throw new ArgumentException($"{type} is not a recognised AvaloniaProperty ({_styledPropertyType}, {_attachedPropertyType}, {_directPropertyType}).", nameof(type)); |
||||
|
} |
||||
|
|
||||
|
private ImmutableHashSet<AvaloniaPropertyDescription> GetAvaloniaPropertiesForType(ITypeSymbol type) |
||||
|
{ |
||||
|
var properties = new List<AvaloniaPropertyDescription>(); |
||||
|
|
||||
|
var current = type; |
||||
|
while (current != null) |
||||
|
{ |
||||
|
properties.AddRange(current.GetMembers().Intersect(_avaloniaProperyDescriptions.Keys, SymbolEqualityComparer.Default).Select(s => _avaloniaProperyDescriptions[s])); |
||||
|
current = current.BaseType; |
||||
|
} |
||||
|
|
||||
|
return properties.ToImmutableHashSet(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,253 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Concurrent; |
||||
|
using System.Collections.Immutable; |
||||
|
using System.Linq; |
||||
|
using System.Runtime.Serialization; |
||||
|
using Microsoft.CodeAnalysis; |
||||
|
using Microsoft.CodeAnalysis.Diagnostics; |
||||
|
|
||||
|
namespace Avalonia.Analyzers; |
||||
|
|
||||
|
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)] |
||||
|
public partial class AvaloniaPropertyAnalyzer : DiagnosticAnalyzer |
||||
|
{ |
||||
|
private const string Category = "AvaloniaProperty"; |
||||
|
|
||||
|
private const string TypeMismatchTag = "TypeMismatch"; |
||||
|
private const string NameCollisionTag = "NameCollision"; |
||||
|
private const string AssociatedPropertyTag = "AssociatedProperty"; |
||||
|
|
||||
|
private static readonly DiagnosticDescriptor AssociatedAvaloniaProperty = new( |
||||
|
"AVP0001", |
||||
|
"Identify the AvaloniaProperty associated with a CLR property", |
||||
|
"Associated AvaloniaProperty: {0} {1}", |
||||
|
Category, |
||||
|
DiagnosticSeverity.Info, |
||||
|
isEnabledByDefault: false, |
||||
|
"This informational diagnostic identifies which AvaloniaProperty a CLR property is associated with.", |
||||
|
AssociatedPropertyTag); |
||||
|
|
||||
|
private static readonly DiagnosticDescriptor InappropriatePropertyAssignment = new( |
||||
|
"AVP1000", |
||||
|
"Store AvaloniaProperty objects appropriately", |
||||
|
"Incorrect AvaloniaProperty storage: {0} should be static and readonly", |
||||
|
Category, |
||||
|
DiagnosticSeverity.Warning, |
||||
|
isEnabledByDefault: true, |
||||
|
"AvaloniaProperty objects have static lifetimes and should be stored accordingly. Do not multiply construct the same property."); |
||||
|
|
||||
|
private static readonly DiagnosticDescriptor OwnerDoesNotMatchOuterType = new( |
||||
|
"AVP1010", |
||||
|
"Avaloniaproperty objects should declare their owner to be the type in which they are stored", |
||||
|
"Type mismatch: AvaloniaProperty owner is {0}, which is not the containing type", |
||||
|
Category, |
||||
|
DiagnosticSeverity.Warning, |
||||
|
isEnabledByDefault: true, |
||||
|
"The owner of an AvaloniaProperty should generally be the containing type. This ensures that the property can be used as expected in XAML.", |
||||
|
TypeMismatchTag); |
||||
|
|
||||
|
private static readonly DiagnosticDescriptor DuplicatePropertyName = new( |
||||
|
"AVP1020", |
||||
|
"AvaloniaProperty names should be unique within each class", |
||||
|
"Name collision: {0} has the same name as {1}", |
||||
|
Category, |
||||
|
DiagnosticSeverity.Warning, |
||||
|
isEnabledByDefault: true, |
||||
|
"Querying for an AvaloniaProperty by name requires that each property associated with a type have a unique name.", |
||||
|
NameCollisionTag); |
||||
|
|
||||
|
private static readonly DiagnosticDescriptor AmbiguousPropertyName = new( |
||||
|
"AVP1021", |
||||
|
"Ensure an umabiguous relationship between CLR properties and Avalonia properties within the same class", |
||||
|
"Name collision: {0} owns multiple Avalonia properties with the name '{1}' {2}", |
||||
|
Category, |
||||
|
DiagnosticSeverity.Warning, |
||||
|
isEnabledByDefault: true, |
||||
|
"It is unclear which AvaloniaProperty this CLR property refers to. Ensure that each AvaloniaProperty associated with a type has a unique name. If you need to change behaviour of a base property in your class, call its AddOwner method and provide new metadata.", |
||||
|
NameCollisionTag); |
||||
|
|
||||
|
private static readonly DiagnosticDescriptor PropertyNameMismatch = new( |
||||
|
"AVP1022", |
||||
|
"Store each AvaloniaProperty object in a field or CLR property which reflects its name", |
||||
|
"Bad name: An AvaloniaProperty named '{0}' is being assigned to {1}. These names do not relate.", |
||||
|
Category, |
||||
|
DiagnosticSeverity.Warning, |
||||
|
isEnabledByDefault: true, |
||||
|
"An AvaloniaProperty should be stored in a field or property which contains its name. For example, a property named \"Brush\" should be assigned to a field called \"BrushProperty\".", |
||||
|
NameCollisionTag); |
||||
|
|
||||
|
private static readonly DiagnosticDescriptor AccessorSideEffects = new( |
||||
|
"AVP1030", |
||||
|
"Do not add side effects to StyledProperty accessors", |
||||
|
"Side effects: '{0}' is an AvaloniaProperty which can be {1} without the use of this CLR property. This {2} accessor should do nothing except call {3}.", |
||||
|
Category, |
||||
|
DiagnosticSeverity.Warning, |
||||
|
isEnabledByDefault: true, |
||||
|
"The AvaloniaObject.GetValue and AvaloniaObject.SetValue methods are public, and do not call any user CLR properties. To execute code before or after the property is set, create a Coerce method or a PropertyChanged subscriber.", |
||||
|
AssociatedPropertyTag); |
||||
|
|
||||
|
private static readonly DiagnosticDescriptor MissingAccessor = new( |
||||
|
"AVP1031", |
||||
|
"A CLR property should support the same get/set operations as its associated AvaloniaProperty", |
||||
|
"Missing accessor: {0} is {1}, but this CLR property lacks a {2} accessor", |
||||
|
Category, |
||||
|
DiagnosticSeverity.Warning, |
||||
|
isEnabledByDefault: true, |
||||
|
"The AvaloniaObject.GetValue and AvaloniaObject.SetValue methods are public, and do not call CLR properties on the owning type. Not providing both CLR property accessors is ineffective.", |
||||
|
AssociatedPropertyTag); |
||||
|
|
||||
|
private static readonly DiagnosticDescriptor InconsistentAccessibility = new( |
||||
|
"AVP1032", |
||||
|
"A CLR property and its accessors should be equally accessible as its associated AvaloniaProperty", |
||||
|
"Inconsistent accessibility: CLR {0} accessiblity does not match accessibility of {1}", |
||||
|
Category, |
||||
|
DiagnosticSeverity.Warning, |
||||
|
isEnabledByDefault: true, |
||||
|
"The AvaloniaObject.GetValue and AvaloniaObject.SetValue methods are public, and do not call CLR properties on the owning type. Defining a CLR property with different acessibility from its associated AvaloniaProperty is ineffective.", |
||||
|
AssociatedPropertyTag); |
||||
|
|
||||
|
private static readonly DiagnosticDescriptor PropertyTypeMismatch = new( |
||||
|
"AVP1040", |
||||
|
"CLR property type should match associated AvaloniaProperty type", |
||||
|
"Type mismatch: CLR property type differs from the value type of {0} {1}", |
||||
|
Category, |
||||
|
DiagnosticSeverity.Warning, |
||||
|
isEnabledByDefault: true, |
||||
|
"The AvaloniaObject.GetValue and AvaloniaObject.SetValue methods are public, and do not call CLR properties on the owning type. A CLR property changing the value type (even when an implicit cast is possible) is ineffective and can lead to InvalidCastException to be thrown.", |
||||
|
TypeMismatchTag, AssociatedPropertyTag); |
||||
|
|
||||
|
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create( |
||||
|
AssociatedAvaloniaProperty, |
||||
|
InappropriatePropertyAssignment, |
||||
|
OwnerDoesNotMatchOuterType, |
||||
|
DuplicatePropertyName, |
||||
|
AmbiguousPropertyName, |
||||
|
PropertyNameMismatch, |
||||
|
AccessorSideEffects, |
||||
|
MissingAccessor, |
||||
|
InconsistentAccessibility, |
||||
|
PropertyTypeMismatch); |
||||
|
|
||||
|
public override void Initialize(AnalysisContext context) |
||||
|
{ |
||||
|
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); |
||||
|
context.EnableConcurrentExecution(); |
||||
|
|
||||
|
context.RegisterCompilationStartAction(c => new CompileAnalyzer(c)); |
||||
|
} |
||||
|
|
||||
|
private static bool IsAvaloniaPropertyType(INamedTypeSymbol type, params INamedTypeSymbol[] propertyTypes) |
||||
|
{ |
||||
|
if (type.IsGenericType) |
||||
|
{ |
||||
|
type = type.ConstructUnboundGenericType().OriginalDefinition; |
||||
|
} |
||||
|
|
||||
|
return propertyTypes.Any(t => SymbolEquals(type, t)); |
||||
|
} |
||||
|
|
||||
|
private static bool DerivesFrom(ITypeSymbol? type, ITypeSymbol baseType) |
||||
|
{ |
||||
|
while (type != null) |
||||
|
{ |
||||
|
if (SymbolEquals(type, baseType)) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
type = type.BaseType; |
||||
|
} |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
private static bool IsValidAvaloniaPropertyStorage(IFieldSymbol field) => field.IsStatic && field.IsReadOnly; |
||||
|
private static bool IsValidAvaloniaPropertyStorage(IPropertySymbol field) => field.IsStatic && field.IsReadOnly; |
||||
|
|
||||
|
private static bool SymbolEquals(ISymbol? x, ISymbol? y, bool includeNullability = false) |
||||
|
{ |
||||
|
// The current version of Microsoft.CodeAnalysis includes an "IncludeNullability" comparer,
|
||||
|
// but it overshoots the target and tries to compare EVERYTHING. This leads to two symbols for
|
||||
|
// the same type not being equal if they were imported into different compile units (i.e. assemblies).
|
||||
|
// So for now, we will just discard this parameter.
|
||||
|
_ = includeNullability; |
||||
|
|
||||
|
return SymbolEqualityComparer.Default.Equals(x, y); |
||||
|
} |
||||
|
|
||||
|
private class AvaloniaPropertyDescription |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Gets the name that was assigned to this property when it was registered.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// If the property was not registered within the current compile context, this value will be inferred from
|
||||
|
/// the name of the field (or CLR property) in which the AvaloniaProperty object is stored.
|
||||
|
/// </remarks>
|
||||
|
public string Name { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the type of the AvaloniaProperty itself: Styled, Direct, or Attached
|
||||
|
/// </summary>
|
||||
|
public INamedTypeSymbol PropertyType { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the TValue type that the property stores.
|
||||
|
/// </summary>
|
||||
|
public INamedTypeSymbol ValueType { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the type which registered the property, and all types which have added themselves as owners.
|
||||
|
/// </summary>
|
||||
|
public ConcurrentBag<INamedTypeSymbol> OwnerTypes { get; } = new(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets a dictionary which maps fields and properties which were initialized with this AvaloniaProperty to the TOwner specified at each assignment.
|
||||
|
/// </summary>
|
||||
|
public ConcurrentDictionary<ISymbol, INamedTypeSymbol> AssignedTo { get; } = new(SymbolEqualityComparer.Default); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets properties which provide convenient access to the AvaloniaProperty on an instance of an AvaloniaObject.
|
||||
|
/// </summary>
|
||||
|
public ConcurrentBag<IPropertySymbol> PropertyWrappers { get; } = new(); |
||||
|
|
||||
|
public AvaloniaPropertyDescription(string name, INamedTypeSymbol propertyType, INamedTypeSymbol valueType) |
||||
|
{ |
||||
|
Name = name; |
||||
|
PropertyType = propertyType; |
||||
|
ValueType = valueType; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Searches the inheritance hierarchy of the given type for a field or property to which this AvaloniaProperty is assigned.
|
||||
|
/// </summary>
|
||||
|
public ISymbol? ClosestAssignmentFor(ITypeSymbol? type) |
||||
|
{ |
||||
|
var assignmentsByType = AssignedTo.Keys.ToLookup(s => s.ContainingType, SymbolEqualityComparer.Default); |
||||
|
|
||||
|
while (type != null) |
||||
|
{ |
||||
|
if (assignmentsByType.Contains(type)) |
||||
|
{ |
||||
|
return assignmentsByType[type].First(); |
||||
|
} |
||||
|
type = type.BaseType; |
||||
|
} |
||||
|
|
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
|
||||
|
[Serializable] |
||||
|
public class AvaloniaAnalysisException : Exception |
||||
|
{ |
||||
|
public AvaloniaAnalysisException(string message, Exception? innerException = null) : base(message, innerException) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
protected AvaloniaAnalysisException(SerializationInfo info, StreamingContext context) : base(info, context) |
||||
|
{ |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue