diff --git a/Avalonia.Desktop.slnf b/Avalonia.Desktop.slnf index 741570061b..1d182b1357 100644 --- a/Avalonia.Desktop.slnf +++ b/Avalonia.Desktop.slnf @@ -8,9 +8,9 @@ "samples\\GpuInterop\\GpuInterop.csproj", "samples\\IntegrationTestApp\\IntegrationTestApp.csproj", "samples\\MiniMvvm\\MiniMvvm.csproj", + "samples\\ReactiveUIDemo\\ReactiveUIDemo.csproj", "samples\\SampleControls\\ControlSamples.csproj", "samples\\Sandbox\\Sandbox.csproj", - "samples\\ReactiveUIDemo\\ReactiveUIDemo.csproj", "src\\Avalonia.Base\\Avalonia.Base.csproj", "src\\Avalonia.Build.Tasks\\Avalonia.Build.Tasks.csproj", "src\\Avalonia.Controls.ColorPicker\\Avalonia.Controls.ColorPicker.csproj", @@ -42,6 +42,7 @@ "src\\Windows\\Avalonia.Win32\\Avalonia.Win32.csproj", "src\\tools\\DevAnalyzers\\DevAnalyzers.csproj", "src\\tools\\DevGenerators\\DevGenerators.csproj", + "src\\tools\\PublicAnalyzers\\Avalonia.Analyzers.csproj", "tests\\Avalonia.Base.UnitTests\\Avalonia.Base.UnitTests.csproj", "tests\\Avalonia.Benchmarks\\Avalonia.Benchmarks.csproj", "tests\\Avalonia.Controls.DataGrid.UnitTests\\Avalonia.Controls.DataGrid.UnitTests.csproj", diff --git a/Avalonia.sln b/Avalonia.sln index 525e01c891..1e8ee85ffb 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -231,7 +231,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ControlCatalog.Browser.Blaz EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactiveUIDemo", "samples\ReactiveUIDemo\ReactiveUIDemo.csproj", "{75C47156-C5D8-44BC-A5A7-E8657C2248D6}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GpuInterop", "samples\GpuInterop\GpuInterop.csproj", "{C810060E-3809-4B74-A125-F11533AF9C1B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GpuInterop", "samples\GpuInterop\GpuInterop.csproj", "{C810060E-3809-4B74-A125-F11533AF9C1B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Analyzers", "src\tools\PublicAnalyzers\Avalonia.Analyzers.csproj", "{C692FE73-43DB-49CE-87FC-F03ED61F25C9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{176582E8-46AF-416A-85C1-13A5C6744497}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Controls.ItemsRepeater", "src\Avalonia.Controls.ItemsRepeater\Avalonia.Controls.ItemsRepeater.csproj", "{EE0F0DD4-A70D-472B-BD5D-B7D32D0E9386}" EndProject @@ -560,6 +567,10 @@ Global {F4E36AA8-814E-4704-BC07-291F70F45193}.Debug|Any CPU.Build.0 = Debug|Any CPU {F4E36AA8-814E-4704-BC07-291F70F45193}.Release|Any CPU.ActiveCfg = Release|Any CPU {F4E36AA8-814E-4704-BC07-291F70F45193}.Release|Any CPU.Build.0 = Release|Any CPU + {C692FE73-43DB-49CE-87FC-F03ED61F25C9}.Debug|Any CPU.ActiveCfg = Release|Any CPU + {C692FE73-43DB-49CE-87FC-F03ED61F25C9}.Debug|Any CPU.Build.0 = Release|Any CPU + {C692FE73-43DB-49CE-87FC-F03ED61F25C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C692FE73-43DB-49CE-87FC-F03ED61F25C9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -626,6 +637,7 @@ Global {75C47156-C5D8-44BC-A5A7-E8657C2248D6} = {9B9E3891-2366-4253-A952-D08BCEB71098} {C810060E-3809-4B74-A125-F11533AF9C1B} = {9B9E3891-2366-4253-A952-D08BCEB71098} {F4E36AA8-814E-4704-BC07-291F70F45193} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} + {C692FE73-43DB-49CE-87FC-F03ED61F25C9} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {87366D66-1391-4D90-8999-95A620AD786A} diff --git a/build/DevAnalyzers.props b/build/DevAnalyzers.props index 14e4f6a563..7d021d051f 100644 --- a/build/DevAnalyzers.props +++ b/build/DevAnalyzers.props @@ -5,5 +5,10 @@ ReferenceOutputAssembly="false" OutputItemType="Analyzer" SetTargetFramework="TargetFramework=netstandard2.0"/> + diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props index 3b14f0ce12..ac78d9c739 100644 --- a/samples/Directory.Build.props +++ b/samples/Directory.Build.props @@ -6,4 +6,5 @@ 11 + diff --git a/src/tools/DevAnalyzers/DevAnalyzers.csproj b/src/tools/DevAnalyzers/DevAnalyzers.csproj index e5c2fc6cf6..2d9331b5dc 100644 --- a/src/tools/DevAnalyzers/DevAnalyzers.csproj +++ b/src/tools/DevAnalyzers/DevAnalyzers.csproj @@ -6,11 +6,11 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/tools/DevGenerators/DevGenerators.csproj b/src/tools/DevGenerators/DevGenerators.csproj index 30da940514..7e63987d1b 100644 --- a/src/tools/DevGenerators/DevGenerators.csproj +++ b/src/tools/DevGenerators/DevGenerators.csproj @@ -7,11 +7,11 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/src/tools/PublicAnalyzers/Avalonia.Analyzers.csproj b/src/tools/PublicAnalyzers/Avalonia.Analyzers.csproj new file mode 100644 index 0000000000..31b8d08541 --- /dev/null +++ b/src/tools/PublicAnalyzers/Avalonia.Analyzers.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + enable + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/tools/PublicAnalyzers/AvaloniaPropertyAnalyzer.CompileAnalyzer.cs b/src/tools/PublicAnalyzers/AvaloniaPropertyAnalyzer.CompileAnalyzer.cs new file mode 100644 index 0000000000..f11ed838a5 --- /dev/null +++ b/src/tools/PublicAnalyzers/AvaloniaPropertyAnalyzer.CompileAnalyzer.cs @@ -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 + { + /// + /// A dictionary that maps field/property symbols to the AvaloniaProperty objects assigned to them. + /// + private readonly ConcurrentDictionary _avaloniaProperyDescriptions = new(SymbolEqualityComparer.Default); + + private readonly ConcurrentDictionary> _clrPropertyToAvaloniaProperties = new(SymbolEqualityComparer.Default); + + private readonly INamedTypeSymbol _avaloniaObjectType; + private readonly ImmutableHashSet _getValueMethods; + private readonly ImmutableHashSet _setValueMethods; + private readonly INamedTypeSymbol _avaloniaPropertyType; + private readonly INamedTypeSymbol _styledPropertyType; + private readonly INamedTypeSymbol _attachedPropertyType; + private readonly INamedTypeSymbol _directPropertyType; + private readonly ImmutableHashSet _avaloniaPropertyRegisterMethods; + private readonly ImmutableHashSet _avaloniaPropertyAddOwnerMethods; + + public CompileAnalyzer(CompilationStartAnalysisContext context) + { + _avaloniaObjectType = GetTypeOrThrow("Avalonia.AvaloniaObject"); + _getValueMethods = _avaloniaObjectType.GetMembers("GetValue").OfType().ToImmutableHashSet(SymbolEqualityComparer.Default); + _setValueMethods = _avaloniaObjectType.GetMembers("SetValue").OfType().ToImmutableHashSet(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().Where(m => m.Name.StartsWith("Register")).ToImmutableHashSet(SymbolEqualityComparer.Default); + + _avaloniaPropertyAddOwnerMethods = new[] { _styledPropertyType, _attachedPropertyType, _directPropertyType } + .SelectMany(t => t.GetMembers("AddOwner").OfType()).ToImmutableHashSet(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(); + namespaceStack.Push(compilation.GlobalNamespace); + + var types = new List(); + + 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> 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(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().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 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, "(?.*)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 GetAvaloniaPropertiesForType(ITypeSymbol type) + { + var properties = new List(); + + 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(); + } + } +} diff --git a/src/tools/PublicAnalyzers/AvaloniaPropertyAnalyzer.cs b/src/tools/PublicAnalyzers/AvaloniaPropertyAnalyzer.cs new file mode 100644 index 0000000000..e54ac397fb --- /dev/null +++ b/src/tools/PublicAnalyzers/AvaloniaPropertyAnalyzer.cs @@ -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 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 + { + /// + /// Gets the name that was assigned to this property when it was registered. + /// + /// + /// 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. + /// + public string Name { get; set; } + + /// + /// Gets the type of the AvaloniaProperty itself: Styled, Direct, or Attached + /// + public INamedTypeSymbol PropertyType { get; } + + /// + /// Gets the TValue type that the property stores. + /// + public INamedTypeSymbol ValueType { get; } + + /// + /// Gets the type which registered the property, and all types which have added themselves as owners. + /// + public ConcurrentBag OwnerTypes { get; } = new(); + + /// + /// Gets a dictionary which maps fields and properties which were initialized with this AvaloniaProperty to the TOwner specified at each assignment. + /// + public ConcurrentDictionary AssignedTo { get; } = new(SymbolEqualityComparer.Default); + + /// + /// Gets properties which provide convenient access to the AvaloniaProperty on an instance of an AvaloniaObject. + /// + public ConcurrentBag PropertyWrappers { get; } = new(); + + public AvaloniaPropertyDescription(string name, INamedTypeSymbol propertyType, INamedTypeSymbol valueType) + { + Name = name; + PropertyType = propertyType; + ValueType = valueType; + } + + /// + /// Searches the inheritance hierarchy of the given type for a field or property to which this AvaloniaProperty is assigned. + /// + 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) + { + } +}