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