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