Browse Source

Added Avalonia.Analyzers and ten diagonstics

Tom Edwards 2 years ago
parent
commit
1fbd4ab801

+ 2 - 1
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",

+ 13 - 1
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}

+ 5 - 0
build/DevAnalyzers.props

@@ -5,5 +5,10 @@
                       ReferenceOutputAssembly="false"
                       OutputItemType="Analyzer"
                       SetTargetFramework="TargetFramework=netstandard2.0"/>
+    <ProjectReference Include="$(MSBuildThisFileDirectory)..\src\tools\PublicAnalyzers\Avalonia.Analyzers.csproj"
+                      PrivateAssets="all"
+                      ReferenceOutputAssembly="false"
+                      OutputItemType="Analyzer"
+                      SetTargetFramework="TargetFramework=netstandard2.0"/>
   </ItemGroup>
 </Project>

+ 1 - 0
samples/Directory.Build.props

@@ -6,4 +6,5 @@
       <LangVersion>11</LangVersion>
   </PropertyGroup>
   <Import Project="..\build\SharedVersion.props" />
+  <Import Project="..\build\DevAnalyzers.props" />
 </Project>

+ 2 - 2
src/tools/DevAnalyzers/DevAnalyzers.csproj

@@ -6,11 +6,11 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3">
+    <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.1.0" />
+    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.4.0" />
   </ItemGroup>
 
 </Project>

+ 2 - 2
src/tools/DevGenerators/DevGenerators.csproj

@@ -7,11 +7,11 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3">
+    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4">
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
       <PrivateAssets>all</PrivateAssets>
     </PackageReference>
-    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.1.0" />
+    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.4.0" />
     <Compile Include="..\..\Shared\SourceGeneratorAttributes.cs" />
     <Compile Include="..\..\Shared\IsExternalInit.cs" Link="IsExternalInit.cs" />
   </ItemGroup>

+ 17 - 0
src/tools/PublicAnalyzers/Avalonia.Analyzers.csproj

@@ -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>

+ 555 - 0
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
+    {
+        /// <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();
+        }
+    }
+}

+ 253 - 0
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<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)
+    {
+    }
+}