diff --git a/src/Controls/src/SourceGen/ITypeSymbolExtensions.Maui.cs b/src/Controls/src/SourceGen/ITypeSymbolExtensions.Maui.cs index 0b09b9bab3ff..0e7a1a655fd4 100644 --- a/src/Controls/src/SourceGen/ITypeSymbolExtensions.Maui.cs +++ b/src/Controls/src/SourceGen/ITypeSymbolExtensions.Maui.cs @@ -27,6 +27,119 @@ static partial class ITypeSymbolExtensions return bpParentType.GetAllFields(name, context).FirstOrDefault(fi => fi.IsStatic && fi.Type.Equals(context.Compilation.GetTypeByMetadataName("Microsoft.Maui.Controls.BindableProperty"), SymbolEqualityComparer.Default)); } + /// + /// Checks if there's a property or field with a similar name that has a BindablePropertyAttribute. + /// This is a heuristic to support bindable properties generated by other source generators. + /// Returns the explicit property name if specified in the attribute, otherwise null. + /// + public static bool HasBindablePropertyHeuristic(this ITypeSymbol type, string propertyName, SourceGenContext context, out string? explicitPropertyName) + { + explicitPropertyName = null; + + // Check if any property name variant has the BindablePropertyAttribute or AutoPropertyAttribute + foreach (var name in DerivePotentialPropertyNames(propertyName)) + { + var property = type.GetAllProperties(name, context).FirstOrDefault(); + if (property != null && HasBindablePropertyOrAutoPropertyAttribute(property, out var propName)) + { + explicitPropertyName = propName; + return true; + } + } + + // Check if any field name variant has the BindablePropertyAttribute or AutoPropertyAttribute + foreach (var name in DerivePotentialFieldNames(propertyName)) + { + var field = type.GetAllFields(name, context).FirstOrDefault(); + if (field != null && HasBindablePropertyOrAutoPropertyAttribute(field, out var propName)) + { + explicitPropertyName = propName; + return true; + } + } + + return false; + } + + /// + /// Derives potential property name variations from a XAML property name. + /// + private static IEnumerable DerivePotentialPropertyNames(string propertyName) + { + if (string.IsNullOrEmpty(propertyName)) + yield break; + + // Exact match (e.g., "Balance") + yield return propertyName; + } + + /// + /// Derives potential field name variations from a XAML property name. + /// + private static IEnumerable DerivePotentialFieldNames(string propertyName) + { + if (string.IsNullOrEmpty(propertyName)) + yield break; + + // Underscore prefix with lowercase first letter (e.g., "_balance" for "Balance") + yield return $"_{char.ToLowerInvariant(propertyName[0])}{propertyName.Substring(1)}"; + + // Lowercase first letter (e.g., "balance" for "Balance") + yield return $"{char.ToLowerInvariant(propertyName[0])}{propertyName.Substring(1)}"; + + // Uppercase first letter (e.g., "Balance" for "Balance" - uncommon but supported) + yield return propertyName; + } + + /// + /// List of recognized attribute full names that indicate a bindable property will be generated. + /// Easy to extend by adding new attribute names to this list. + /// + private static readonly string[] RecognizedBindablePropertyAttributes = new[] + { + "CommunityToolkit.Maui.BindablePropertyAttribute", + "SQuan.Helpers.Maui.Mvvm.BindablePropertyAttribute", + "Maui.BindableProperty.Generator.Core.AutoBindableAttribute" + }; + + /// + /// Checks if a symbol has a recognized bindable property attribute. + /// If the attribute has a PropertyName parameter, returns it in the out parameter. + /// + private static bool HasBindablePropertyOrAutoPropertyAttribute(ISymbol symbol, out string? explicitPropertyName) + { + explicitPropertyName = null; + + foreach (var attr in symbol.GetAttributes()) + { + var attrClass = attr.AttributeClass; + if (attrClass == null) + continue; + + var fullTypeName = attrClass.ToString(); + + // Check if the attribute is in our list of recognized attributes + if (RecognizedBindablePropertyAttributes.Contains(fullTypeName)) + { + // Try to get the PropertyName named parameter + foreach (var namedArg in attr.NamedArguments) + { + if (namedArg.Key == "PropertyName" && namedArg.Value.Value is string propName) + { + explicitPropertyName = propName; + return true; + } + } + + // Attribute found but no explicit PropertyName + explicitPropertyName = null; + return true; + } + } + + return false; + } + static bool GetNameAndTypeRef(ref ITypeSymbol elementType, string namespaceURI, ref string localname, SourceGenContext context, IXmlLineInfo? lineInfo) { diff --git a/src/Controls/src/SourceGen/SetPropertyHelpers.cs b/src/Controls/src/SourceGen/SetPropertyHelpers.cs index a2c893990b53..0805bd2d48c0 100644 --- a/src/Controls/src/SourceGen/SetPropertyHelpers.cs +++ b/src/Controls/src/SourceGen/SetPropertyHelpers.cs @@ -45,16 +45,16 @@ public static void SetPropertyValue(IndentedTextWriter writer, ILocalValue paren } //If it's a BP and the value is BindingBase, SetBinding - if (!asCollectionItem && CanSetBinding(bpFieldSymbol, valueNode, context)) + if (!asCollectionItem && CanSetBinding(bpFieldSymbol, valueNode, parentVar.Type, localName, context, out var explicitPropertyNameForBinding)) { - SetBinding(writer, parentVar, bpFieldSymbol!, valueNode, context, getNodeValue); + SetBinding(writer, parentVar, bpFieldSymbol, localName, explicitPropertyNameForBinding, valueNode, context, getNodeValue); return; } //If it's a BP, SetValue - if (!asCollectionItem && CanSetValue(bpFieldSymbol, valueNode, context, getNodeValue)) + if (!asCollectionItem && CanSetValue(bpFieldSymbol, valueNode, parentVar.Type, localName, context, getNodeValue, out var explicitPropertyNameForValue)) { - SetValue(writer, parentVar, bpFieldSymbol!, valueNode, context, getNodeValue); + SetValue(writer, parentVar, bpFieldSymbol, localName, explicitPropertyNameForValue, valueNode, context, getNodeValue); return; } @@ -232,73 +232,106 @@ static void ConnectEvent(IndentedTextWriter writer, ILocalValue parentVar, strin } } - static bool CanSetValue(IFieldSymbol? bpFieldSymbol, INode node, SourceGenContext context, NodeSGExtensions.GetNodeValueDelegate getNodeValue) + static bool CanSetValue(IFieldSymbol? bpFieldSymbol, INode node, ITypeSymbol parentType, string localName, SourceGenContext context, NodeSGExtensions.GetNodeValueDelegate getNodeValue, out string? explicitPropertyName) { - if (bpFieldSymbol == null) - return false; - if (node is ValueNode vn && vn.CanConvertTo(bpFieldSymbol, context)) - return true; - if (node is not ElementNode en) - return false; + explicitPropertyName = null; + + if (bpFieldSymbol != null) + { + // Normal BP case - apply existing logic + if (node is ValueNode vn && vn.CanConvertTo(bpFieldSymbol, context)) + return true; + if (node is not ElementNode en) + return false; - var localVar = getNodeValue(en, context.Compilation.ObjectType); + var localVar = getNodeValue(en, context.Compilation.ObjectType); - // If it's an attached BP, there's no second chance to handle IMarkupExtensions, so we try here. - // Worst case scenario ? InvalidCastException at runtime - if (localVar.Type.Equals(context.Compilation.ObjectType, SymbolEqualityComparer.Default)) - return true; + // If it's an attached BP, there's no second chance to handle IMarkupExtensions, so we try here. + // Worst case scenario ? InvalidCastException at runtime + if (localVar.Type.Equals(context.Compilation.ObjectType, SymbolEqualityComparer.Default)) + return true; - var bpTypeAndConverter = bpFieldSymbol.GetBPTypeAndConverter(context); - if (context.Compilation.HasImplicitConversion(localVar.Type, bpTypeAndConverter?.type)) - return true; + var bpTypeAndConverter = bpFieldSymbol.GetBPTypeAndConverter(context); + if (context.Compilation.HasImplicitConversion(localVar.Type, bpTypeAndConverter?.type)) + return true; - if (HasDoubleImplicitConversion(localVar.Type, bpTypeAndConverter?.type, context, out _)) - return true; + if (HasDoubleImplicitConversion(localVar.Type, bpTypeAndConverter?.type, context, out _)) + return true; - if (HasExplicitConversion(localVar.Type, bpTypeAndConverter?.type, context)) - return true; + if (HasExplicitConversion(localVar.Type, bpTypeAndConverter?.type, context)) + return true; - if (localVar.Type.InheritsFrom(bpTypeAndConverter?.type!, context)) - return true; + if (localVar.Type.InheritsFrom(bpTypeAndConverter?.type!, context)) + return true; - if (bpFieldSymbol.Type.IsInterface() && localVar.Type.Implements(bpTypeAndConverter?.type!)) - return true; + if (bpFieldSymbol.Type.IsInterface() && localVar.Type.Implements(bpTypeAndConverter?.type!)) + return true; + return false; + } + + // Heuristic: If BP is null but the type has a property/field with a BindablePropertyAttribute, + // assume the BP will be generated by another source generator + // Only apply this for non-BindingBase nodes (CanSetBinding handles BindingBase) + if (!string.IsNullOrEmpty(localName) && !IsBindingBaseNode(node, context)) + { + return parentType.HasBindablePropertyHeuristic(localName, context, out explicitPropertyName); + } + return false; } - static void SetValue(IndentedTextWriter writer, ILocalValue parentVar, IFieldSymbol bpFieldSymbol, INode node, SourceGenContext context, NodeSGExtensions.GetNodeValueDelegate getNodeValue) + static void SetValue(IndentedTextWriter writer, ILocalValue parentVar, IFieldSymbol? bpFieldSymbol, string localName, string? explicitPropertyName, INode node, SourceGenContext context, NodeSGExtensions.GetNodeValueDelegate getNodeValue) { - var pType = bpFieldSymbol.GetBPTypeAndConverter(context)?.type; + // Determine bindable property name: use BP field symbol if available, otherwise use heuristic + var bpName = bpFieldSymbol != null + ? bpFieldSymbol.ToFQDisplayString() + : $"{parentVar.Type.ToFQDisplayString()}.{explicitPropertyName ?? $"{localName}Property"}"; + + var pType = bpFieldSymbol?.GetBPTypeAndConverter(context)?.type; + var property = bpFieldSymbol == null ? parentVar.Type.GetAllProperties(localName, context).FirstOrDefault() : null; + if (node is ValueNode valueNode) { using (context.ProjectItem.EnableLineInfo ? PrePost.NewLineInfo(writer, (IXmlLineInfo)node, context.ProjectItem) : PrePost.NoBlock()) { - var valueString = valueNode.ConvertTo(bpFieldSymbol, writer,context, parentVar); - writer.WriteLine($"{parentVar.ValueAccessor}.SetValue({bpFieldSymbol.ToFQDisplayString()}, {valueString});"); + var valueString = bpFieldSymbol != null + ? valueNode.ConvertTo(bpFieldSymbol, writer, context, parentVar) + : (property != null ? valueNode.ConvertTo(property, writer, context, parentVar) : getNodeValue(node, context.Compilation.ObjectType).ValueAccessor); + writer.WriteLine($"{parentVar.ValueAccessor}.SetValue({bpName}, {valueString});"); } } else if (node is ElementNode elementNode) + { using (context.ProjectItem.EnableLineInfo ? PrePost.NewLineInfo(writer, (IXmlLineInfo)node, context.ProjectItem) : PrePost.NoBlock()) { var localVar = getNodeValue(elementNode, context.Compilation.ObjectType); - string cast = string.Empty; + var cast = string.Empty; - if (HasDoubleImplicitConversion(localVar.Type, pType, context, out var conv)) + if (bpFieldSymbol != null) { - cast = "(" + conv!.ReturnType.ToFQDisplayString() + ")"; - } - else if (pType != null && !context.Compilation.HasImplicitConversion(localVar.Type, pType) && HasExplicitConversion(localVar.Type, pType, context)) - { - // Only add cast if the source type is not object (object can be cast to anything at runtime) - if (!localVar.Type.Equals(context.Compilation.ObjectType, SymbolEqualityComparer.Default)) + // BP case: check for double implicit conversion first + if (HasDoubleImplicitConversion(localVar.Type, pType, context, out var conv)) { - cast = $"({pType.ToFQDisplayString()})"; + cast = "(" + conv!.ReturnType.ToFQDisplayString() + ")"; } + else if (pType != null && !context.Compilation.HasImplicitConversion(localVar.Type, pType) && HasExplicitConversion(localVar.Type, pType, context)) + { + // Only add cast if the source type is not object (object can be cast to anything at runtime) + if (!localVar.Type.Equals(context.Compilation.ObjectType, SymbolEqualityComparer.Default)) + { + cast = $"({pType.ToFQDisplayString()})"; + } + } + } + else if (property != null && !context.Compilation.HasImplicitConversion(localVar.Type, property.Type)) + { + cast = $"({property.Type.ToFQDisplayString()})"; } - writer.WriteLine($"{parentVar.ValueAccessor}.SetValue({bpFieldSymbol.ToFQDisplayString()}, {cast}{localVar.ValueAccessor});"); + writer.WriteLine($"{parentVar.ValueAccessor}.SetValue({bpName}, {cast}{localVar.ValueAccessor});"); } + } } static bool CanGet(ILocalValue parentVar, string localName, SourceGenContext context, out ITypeSymbol? propertyType, out IPropertySymbol? propertySymbol) @@ -426,31 +459,42 @@ static void Set(IndentedTextWriter writer, ILocalValue parentVar, string localNa } } - static bool CanSetBinding(IFieldSymbol? bpFieldSymbol, INode node, SourceGenContext context) + static bool CanSetBinding(IFieldSymbol? bpFieldSymbol, INode node, ITypeSymbol parentType, string localName, SourceGenContext context, out string? explicitPropertyName) { - - if (bpFieldSymbol == null) - return false; - if (node is not ElementNode en) - return false; - if (!context.Variables.TryGetValue(en, out var localVariable)) + explicitPropertyName = null; + + // Check if it's a BindingBase node + if (!IsBindingBaseNode(node, context)) return false; - - var bindingBaseSymbol = context.Compilation.GetTypeByMetadataName("Microsoft.Maui.Controls.BindingBase")!; - - if (localVariable.Type.InheritsFrom(bindingBaseSymbol, context)) - return true; - - if (context.Compilation.HasImplicitConversion(localVariable.Type, bindingBaseSymbol)) + + // If we have a BP field symbol, we can set binding + if (bpFieldSymbol != null) return true; - + + // Heuristic: If BP is null but the type has a property/field with a BindablePropertyAttribute, + // assume the BP will be generated by another source generator + if (!string.IsNullOrEmpty(localName)) + return parentType.HasBindablePropertyHeuristic(localName, context, out explicitPropertyName); + return false; } - static void SetBinding(IndentedTextWriter writer, ILocalValue parentVar, IFieldSymbol bpFieldSymbol, INode node, SourceGenContext context, NodeSGExtensions.GetNodeValueDelegate getNodeValue) + static void SetBinding(IndentedTextWriter writer, ILocalValue parentVar, IFieldSymbol? bpFieldSymbol, string localName, string? explicitPropertyName, INode node, SourceGenContext context, NodeSGExtensions.GetNodeValueDelegate getNodeValue) { var localVariable = getNodeValue((ElementNode)node, context.Compilation.ObjectType); - writer.WriteLine($"{parentVar.ValueAccessor}.SetBinding({bpFieldSymbol.ToFQDisplayString()}, {localVariable.ValueAccessor});"); + + if (bpFieldSymbol != null) + { + // Normal case: we have the BP field symbol + writer.WriteLine($"{parentVar.ValueAccessor}.SetBinding({bpFieldSymbol.ToFQDisplayString()}, {localVariable.ValueAccessor});"); + } + else + { + // Heuristic case: generate SetBinding call using the expected BindableProperty name + // Use explicit property name if provided by attribute, otherwise use the default {localName}Property format + var bpName = explicitPropertyName ?? $"{localName}Property"; + writer.WriteLine($"{parentVar.ValueAccessor}.SetBinding({parentVar.Type.ToFQDisplayString()}.{bpName}, {localVariable.ValueAccessor});"); + } } static bool CanAdd(ILocalValue parentVar, string localName, IFieldSymbol? bpFieldSymbol, bool attached, INode valueNode, SourceGenContext context, NodeSGExtensions.GetNodeValueDelegate getNodeValue) @@ -534,4 +578,23 @@ static void Add(IndentedTextWriter writer, ILocalValue parentVar, XmlName proper using (context.ProjectItem.EnableLineInfo ? PrePost.NewLineInfo(writer, (IXmlLineInfo)valueNode, context.ProjectItem) : PrePost.NoBlock()) writer.WriteLine($"{parentObj}.Add(({itemType.ToFQDisplayString()}){cast}{getNodeValue(valueNode, context.Compilation.ObjectType).ValueAccessor});"); } + + static bool IsBindingBaseNode(INode node, SourceGenContext context) + { + if (node is not ElementNode en) + return false; + if (!context.Variables.TryGetValue(en, out var localVariable)) + return false; + + var bindingBaseSymbol = context.Compilation.GetTypeByMetadataName("Microsoft.Maui.Controls.BindingBase")!; + + if (localVariable.Type.InheritsFrom(bindingBaseSymbol, context)) + return true; + + if (context.Compilation.HasImplicitConversion(localVariable.Type, bindingBaseSymbol)) + return true; + + return false; + } + } diff --git a/src/Controls/tests/SourceGen.UnitTests/InitializeComponent/BindablePropertyHeuristic.cs b/src/Controls/tests/SourceGen.UnitTests/InitializeComponent/BindablePropertyHeuristic.cs new file mode 100644 index 000000000000..93def153fd92 --- /dev/null +++ b/src/Controls/tests/SourceGen.UnitTests/InitializeComponent/BindablePropertyHeuristic.cs @@ -0,0 +1,386 @@ +using System; +using System.IO; +using System.Linq; +using Xunit; + +namespace Microsoft.Maui.Controls.SourceGen.UnitTests; + +public class BindablePropertyHeuristic : SourceGenXamlInitializeComponentTestBase +{ + [Theory] + [InlineData("CommunityToolkit.Maui", "BindablePropertyAttribute", "", "BalanceProperty")] + [InlineData("CommunityToolkit.Maui", "BindablePropertyAttribute", "CustomBalance", "CustomBalance")] + [InlineData("Maui.BindableProperty.Generator.Core", "AutoBindableAttribute", "", "BalanceProperty")] + [InlineData("Maui.BindableProperty.Generator.Core", "AutoBindableAttribute", "CustomBalance", "CustomBalance")] + public void BindablePropertyHeuristic_WithPropertyAndBinding_ShouldGenerateSetBinding( + string attributeNamespace, + string attributeName, + string? explicitPropertyName, + string expectedPropertyFieldName) + { + var xaml = +""" + + + + + + + +"""; + + // Build the attribute usage with optional PropertyName parameter + var hasExplicitPropertyName = !string.IsNullOrEmpty(explicitPropertyName); + var attributeUsage = hasExplicitPropertyName ? $"[{attributeNamespace}.{attributeName.Replace("Attribute", "", StringComparison.Ordinal)}(PropertyName = \"{explicitPropertyName}\")]" : $"[{attributeNamespace}.{attributeName.Replace("Attribute", "", StringComparison.Ordinal)}]"; + + var code = +$$""" +using System; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; + +namespace {{attributeNamespace}} +{ + // Simulates an attribute from a third-party library + public class {{attributeName}} : Attribute + { + public string? PropertyName { get; set; } + } +} + +namespace Test +{ + [XamlProcessing(XamlInflator.SourceGen)] + public partial class TestPage : ContentPage + { + public TestPage() + { + InitializeComponent(); + } + } + + public class BalanceView : Label + { + {{attributeUsage}} + public double Balance { get; set; } + } +} +"""; + + // assertNoCompilationErrors: false because BalanceProperty is expected to be generated by another source generator + var (result, generated) = RunGenerator(xaml, code, assertNoCompilationErrors: false); + + // Should not have MAUIX2002 error + var mauix2002Diagnostics = result.Diagnostics.Where(d => d.Id == "MAUIX2002").ToList(); + Assert.Empty(mauix2002Diagnostics); + + // Should have generated code with the correct SetBinding call + Assert.NotNull(generated); + Assert.Contains($".SetBinding(global::Test.BalanceView.{expectedPropertyFieldName},", generated, StringComparison.Ordinal); + } + + [Theory] + [InlineData("CommunityToolkit.Maui", "BindablePropertyAttribute", "_balance", "", "BalanceProperty")] + [InlineData("CommunityToolkit.Maui", "BindablePropertyAttribute", "_balance", "CustomBalance", "CustomBalance")] + [InlineData("CommunityToolkit.Maui", "BindablePropertyAttribute", "balance", "", "BalanceProperty")] + [InlineData("Maui.BindableProperty.Generator.Core", "AutoBindableAttribute", "_balance", "", "BalanceProperty")] + [InlineData("Maui.BindableProperty.Generator.Core", "AutoBindableAttribute", "_balance", "CustomBalance", "CustomBalance")] + [InlineData("Maui.BindableProperty.Generator.Core", "AutoBindableAttribute", "balance", "", "BalanceProperty")] + public void BindablePropertyHeuristic_WithFieldAndBinding_ShouldGenerateSetBinding( + string attributeNamespace, + string attributeName, + string fieldName, + string? explicitPropertyName, + string expectedPropertyFieldName) + { + var xaml = +""" + + + + + + + +"""; + + // Build the attribute usage with optional PropertyName parameter + var hasExplicitPropertyName = !string.IsNullOrEmpty(explicitPropertyName); + var attributeUsage = hasExplicitPropertyName ? $"[{attributeNamespace}.{attributeName.Replace("Attribute", "", StringComparison.Ordinal)}(PropertyName = \"{explicitPropertyName}\")]" : $"[{attributeNamespace}.{attributeName.Replace("Attribute", "", StringComparison.Ordinal)}]"; + + var code = +$$""" +using System; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; + +namespace {{attributeNamespace}} +{ + // Simulates an attribute from a third-party library + public class {{attributeName}} : Attribute + { + public string? PropertyName { get; set; } + } +} + +namespace Test +{ + [XamlProcessing(XamlInflator.SourceGen)] + public partial class TestPage : ContentPage + { + public TestPage() + { + InitializeComponent(); + } + } + + public class BalanceView : Label + { + {{attributeUsage}} + private double {{fieldName}}; + } +} +"""; + + // assertNoCompilationErrors: false because BalanceProperty is expected to be generated by another source generator + var (result, generated) = RunGenerator(xaml, code, assertNoCompilationErrors: false); + + // Should not have MAUIX2002 error + var mauix2002Diagnostics = result.Diagnostics.Where(d => d.Id == "MAUIX2002").ToList(); + Assert.Empty(mauix2002Diagnostics); + + // Should have generated code with the correct SetBinding call + Assert.NotNull(generated); + Assert.Contains($".SetBinding(global::Test.BalanceView.{expectedPropertyFieldName},", generated, StringComparison.Ordinal); + } + + [Fact] + public void PropertyWithoutAttribute_ShouldProduceError() + { + var xaml = +""" + + + + + + + +"""; + + var code = +""" +using System; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; + +namespace Test +{ + [XamlProcessing(XamlInflator.SourceGen)] + public partial class TestPage : ContentPage + { + public TestPage() + { + InitializeComponent(); + } + } + + public class BalanceView : Label + { + // Property without BindablePropertyAttribute - should still fail + public double Balance { get; set; } + } +} +"""; + + var (result, generated) = RunGenerator(xaml, code); + + // Should have MAUIX2002 error because there's no BindableProperty and no attribute + var mauix2002Diagnostics = result.Diagnostics.Where(d => d.Id == "MAUIX2002").ToList(); + Assert.NotEmpty(mauix2002Diagnostics); + } + + [Fact] + public void PropertyWithAttribute_ButDifferentName_ShouldProduceError() + { + var xaml = +""" + + + + + + + +"""; + + var code = +""" +using System; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; + +namespace CommunityToolkit.Maui +{ + // Simulates an attribute from a third-party library + public class BindablePropertyAttribute : Attribute + { + } +} + +namespace Test +{ + [XamlProcessing(XamlInflator.SourceGen)] + public partial class TestPage : ContentPage + { + public TestPage() + { + InitializeComponent(); + } + } + + public class BalanceView : Label + { + // Property with completely different name - should still fail + [CommunityToolkit.Maui.BindableProperty] + public double Amount { get; set; } + } +} +"""; + + var (result, generated) = RunGenerator(xaml, code); + + // Should have MAUIX2002 error because the name doesn't match + var mauix2002Diagnostics = result.Diagnostics.Where(d => d.Id == "MAUIX2002").ToList(); + Assert.NotEmpty(mauix2002Diagnostics); + } + + [Fact] + public void PropertyWithAttribute_LiteralValue_ShouldUseSetValue() + { + var xaml = +""" + + + + +"""; + + var code = +""" +using System; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; + +namespace CommunityToolkit.Maui +{ + // Simulates an attribute from a third-party library + public class BindablePropertyAttribute : Attribute + { + public string? PropertyName { get; set; } + } +} + +namespace Test +{ + [XamlProcessing(XamlInflator.SourceGen)] + public partial class TestPage : ContentPage + { + public TestPage() + { + InitializeComponent(); + } + } + + public class BalanceView : Label + { + // Has attribute - literal assignments should use SetValue with the generated BP + [CommunityToolkit.Maui.BindableProperty] + public double Balance { get; set; } + } +} +"""; + + // assertNoCompilationErrors: false because BalanceProperty is expected to be generated by another source generator + var (result, generated) = RunGenerator(xaml, code, assertNoCompilationErrors: false); + + // Should NOT have MAUIX2002 error + var mauix2002Diagnostics = result.Diagnostics.Where(d => d.Id == "MAUIX2002").ToList(); + Assert.Empty(mauix2002Diagnostics); + + // Should use SetValue with the generated BP, not property setter + Assert.NotNull(generated); + Assert.Contains(".SetValue(global::Test.BalanceView.BalanceProperty,", generated, StringComparison.Ordinal); + } + + [Fact] + public void PropertyWithoutAttribute_LiteralValue_ShouldUsePropertySetter() + { + var xaml = +""" + + + + +"""; + + var code = +""" +using System; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; + +namespace Test +{ + [XamlProcessing(XamlInflator.SourceGen)] + public partial class TestPage : ContentPage + { + public TestPage() + { + InitializeComponent(); + } + } + + public class BalanceView : Label + { + // No attribute - should fall back to property setter + public double Balance { get; set; } + } +} +"""; + + var (result, generated) = RunGenerator(xaml, code); + + // Should NOT have MAUIX2002 error + var mauix2002Diagnostics = result.Diagnostics.Where(d => d.Id == "MAUIX2002").ToList(); + Assert.Empty(mauix2002Diagnostics); + + // Should use property setter since there's no attribute + Assert.NotNull(generated); + Assert.Contains(".Balance = ", generated, StringComparison.Ordinal); + } +}