diff --git a/src/Controls/src/BindingSourceGen/AccessExpressionBuilder.cs b/src/Controls/src/BindingSourceGen/AccessExpressionBuilder.cs index 7494bb931fb6..55c4f9a58b8e 100644 --- a/src/Controls/src/BindingSourceGen/AccessExpressionBuilder.cs +++ b/src/Controls/src/BindingSourceGen/AccessExpressionBuilder.cs @@ -15,6 +15,7 @@ public static string ExtendExpression(string previousExpression, IPathPart nextP Cast { TargetType: var targetType } => $"({previousExpression} as {CastTargetName(targetType)})", ConditionalAccess conditionalAccess => ExtendExpression(previousExpression: $"{previousExpression}?", conditionalAccess.Part), IndexAccess { Index: int numericIndex } => $"{previousExpression}[{numericIndex}]", + IndexAccess { Index: EnumIndex enumIndex } => $"{previousExpression}[{enumIndex.FullyQualifiedEnumValue}]", IndexAccess { Index: string stringIndex } => $"{previousExpression}[\"{stringIndex}\"]", MemberAccess { Kind: AccessorKind.Field, IsGetterInaccessible: true } memberAccess => $"{CreateUnsafeFieldAccessorMethodName(memberAccess.MemberName)}({previousExpression})", MemberAccess { Kind: AccessorKind.Property, IsGetterInaccessible: true } memberAccess => $"{CreateUnsafePropertyAccessorGetMethodName(memberAccess.MemberName)}({previousExpression})", diff --git a/src/Controls/src/BindingSourceGen/PathPart.cs b/src/Controls/src/BindingSourceGen/PathPart.cs index fdb0150155e7..d67b299ce70c 100644 --- a/src/Controls/src/BindingSourceGen/PathPart.cs +++ b/src/Controls/src/BindingSourceGen/PathPart.cs @@ -74,6 +74,15 @@ public bool Equals(IPathPart other) } } +/// +/// Represents an enum value used as an index in a binding path. +/// This is distinct from string indices because the generated code should not quote the value. +/// +public sealed record EnumIndex(string FullyQualifiedEnumValue) +{ + public override string ToString() => FullyQualifiedEnumValue; +} + public sealed record ConditionalAccess(IPathPart Part) : IPathPart { public string? PropertyName => Part.PropertyName; diff --git a/src/Controls/src/BindingSourceGen/Setter.cs b/src/Controls/src/BindingSourceGen/Setter.cs index ea1ac18ec7e0..0e07c7cc9440 100644 --- a/src/Controls/src/BindingSourceGen/Setter.cs +++ b/src/Controls/src/BindingSourceGen/Setter.cs @@ -73,6 +73,7 @@ public static string BuildAssignmentStatement(string accessAccumulator, IPathPar IndexAccess indexAccess => indexAccess.Index switch { int numericIndex => $"{accessAccumulator}[{numericIndex}] = {assignedValueExpression};", + EnumIndex enumIndex => $"{accessAccumulator}[{enumIndex.FullyQualifiedEnumValue}] = {assignedValueExpression};", string stringIndex => $"{accessAccumulator}[\"{stringIndex}\"] = {assignedValueExpression};", _ => throw new NotSupportedException($"Unsupported index type: {indexAccess.Index.GetType()}"), }, diff --git a/src/Controls/src/Build.Tasks/SetPropertiesVisitor.cs b/src/Controls/src/Build.Tasks/SetPropertiesVisitor.cs index c3ae743dcbf0..09ce0fd9abae 100644 --- a/src/Controls/src/Build.Tasks/SetPropertiesVisitor.cs +++ b/src/Controls/src/Build.Tasks/SetPropertiesVisitor.cs @@ -720,12 +720,21 @@ static bool TryParsePath(ILContext context, string path, TypeReference tSourceRe && pd.GetMethod != null && TypeRefComparer.Default.Equals(pd.GetMethod.Parameters[0].ParameterType.ResolveGenericParameters(previousPartTypeRef), module.ImportReference(context.Cache, ("mscorlib", "System", "Object"))) && pd.GetMethod.IsPublic, out indexerDeclTypeRef); + // Try to find an indexer with an enum parameter type + indexer ??= previousPartTypeRef.GetProperty(context.Cache, + pd => pd.Name == indexerName + && pd.GetMethod != null + && pd.GetMethod.Parameters[0].ParameterType.ResolveGenericParameters(previousPartTypeRef).ResolveCached(context.Cache)?.IsEnum == true + && pd.GetMethod.IsPublic, out indexerDeclTypeRef); properties.Add((indexer, indexerDeclTypeRef, indexArg)); if (indexer != null) //the case when we index on an array, not a list { var indexType = indexer.GetMethod.Parameters[0].ParameterType.ResolveGenericParameters(indexerDeclTypeRef); - if (!TypeRefComparer.Default.Equals(indexType, module.TypeSystem.String) && !TypeRefComparer.Default.Equals(indexType, module.TypeSystem.Int32)) + var indexTypeDef = indexType.ResolveCached(context.Cache); + if (!TypeRefComparer.Default.Equals(indexType, module.TypeSystem.String) + && !TypeRefComparer.Default.Equals(indexType, module.TypeSystem.Int32) + && indexTypeDef?.IsEnum != true) throw new BuildException(BindingIndexerTypeUnsupported, lineInfo, null, indexType.FullName); previousPartTypeRef = indexer.PropertyType.ResolveGenericParameters(indexerDeclTypeRef); } @@ -743,7 +752,7 @@ static bool TryParsePath(ILContext context, string path, TypeReference tSourceRe return true; } - static IEnumerable DigProperties(IEnumerable<(PropertyDefinition property, TypeReference propDeclTypeRef, string indexArg)> properties, Dictionary locs, Func fallback, IXmlLineInfo lineInfo, ModuleDefinition module) + static IEnumerable DigProperties(IEnumerable<(PropertyDefinition property, TypeReference propDeclTypeRef, string indexArg)> properties, Dictionary locs, Func fallback, IXmlLineInfo lineInfo, ModuleDefinition module, XamlCache cache = null) { var first = true; @@ -781,7 +790,25 @@ static IEnumerable DigProperties(IEnumerable<(PropertyDefinition pr else if (TypeRefComparer.Default.Equals(indexType, module.TypeSystem.Int32) && int.TryParse(indexArg, out index)) yield return Create(Ldc_I4, index); else - throw new BuildException(BindingIndexerParse, lineInfo, null, indexArg, property.Name); + { + // Try to handle enum types + var indexTypeDef = cache != null ? indexType.ResolveCached(cache) : indexType.Resolve(); + if (indexTypeDef?.IsEnum == true) + { + // Find the enum field with the matching name + var enumField = indexTypeDef.Fields.FirstOrDefault(f => f.IsStatic && f.Name == indexArg); + if (enumField != null) + { + // Load the enum value as an integer constant + var enumValue = Convert.ToInt32(enumField.Constant); + yield return Create(Ldc_I4, enumValue); + } + else + throw new BuildException(BindingIndexerParse, lineInfo, null, indexArg, property.Name); + } + else + throw new BuildException(BindingIndexerParse, lineInfo, null, indexArg, property.Name); + } } } @@ -860,7 +887,7 @@ static IEnumerable CompiledBindingGetGetter(TypeReference tSourceRe pop = Create(Pop); return pop; - }, node as IXmlLineInfo, module)); + }, node as IXmlLineInfo, module, context.Cache)); foreach (var loc in locs.Values) getter.Body.Variables.Add(loc); @@ -950,7 +977,7 @@ static IEnumerable CompiledBindingGetSetter(TypeReference tSourceRe pop = Create(Pop); return pop; - }, node as IXmlLineInfo, module)); + }, node as IXmlLineInfo, module, context.Cache)); foreach (var loc in locs.Values) setter.Body.Variables.Add(loc); @@ -1076,7 +1103,7 @@ static IEnumerable CompiledBindingGetHandlers(TypeReference tSource il.Emit(Ldarg_0); var lastGetterTypeRef = properties[i - 1].property?.PropertyType; var locs = new Dictionary(); - il.Append(DigProperties(properties.Take(i), locs, null, node as IXmlLineInfo, module)); + il.Append(DigProperties(properties.Take(i), locs, null, node as IXmlLineInfo, module, context.Cache)); foreach (var loc in locs.Values) partGetter.Body.Variables.Add(loc); if (lastGetterTypeRef != null && lastGetterTypeRef.IsValueType) diff --git a/src/Controls/src/Core/BindingExpression.cs b/src/Controls/src/Core/BindingExpression.cs index aaf80c3fcd1e..75ee9aaec0fc 100644 --- a/src/Controls/src/Core/BindingExpression.cs +++ b/src/Controls/src/Core/BindingExpression.cs @@ -293,6 +293,16 @@ PropertyInfo GetIndexer(TypeInfo sourceType, string indexerName, string content) return pi; } + //try to find an indexer taking an enum that matches the content + foreach (var pi in sourceType.DeclaredProperties) + { + if (pi.Name != indexerName) + continue; + var paramType = pi.CanRead ? pi.GetMethod.GetParameters()[0].ParameterType : null; + if (paramType != null && paramType.IsEnum && Enum.IsDefined(paramType, content)) + return pi; + } + //try to fallback to an object indexer foreach (var pi in sourceType.DeclaredProperties) { @@ -387,7 +397,16 @@ void SetupPart(TypeInfo sourceType, BindingExpressionPart part) { try { - object arg = Convert.ChangeType(part.Content, parameter.ParameterType, CultureInfo.InvariantCulture); + object arg; + if (parameter.ParameterType.IsEnum) + { + // Handle enum types - parse the string to enum + arg = Enum.Parse(parameter.ParameterType, part.Content); + } + else + { + arg = Convert.ChangeType(part.Content, parameter.ParameterType, CultureInfo.InvariantCulture); + } part.Arguments = new[] { arg }; } catch (FormatException) @@ -399,6 +418,10 @@ void SetupPart(TypeInfo sourceType, BindingExpressionPart part) catch (OverflowException) { } + catch (ArgumentException) + { + // Enum.Parse throws ArgumentException for invalid enum values + } } } } diff --git a/src/Controls/src/SourceGen/CompiledBindingMarkup.cs b/src/Controls/src/SourceGen/CompiledBindingMarkup.cs index d943386940a9..caa979b5947e 100644 --- a/src/Controls/src/SourceGen/CompiledBindingMarkup.cs +++ b/src/Controls/src/SourceGen/CompiledBindingMarkup.cs @@ -455,11 +455,48 @@ bool TryParsePath( && property.Parameters.Length == 1 && property.Parameters[0].Type.SpecialType == SpecialType.System_Object); + // Try to find an indexer with an enum parameter type + indexer ??= previousPartType + .GetAllProperties(indexerName, _context) + .FirstOrDefault(property => + property.GetMethod != null + && !property.GetMethod.IsStatic + && property.Parameters.Length == 1 + && property.Parameters[0].Type.TypeKind == TypeKind.Enum); + + // Fallback: try to find any indexer with enum parameter + indexer ??= previousPartType + .GetAllProperties(_context) + .FirstOrDefault(property => + property.IsIndexer + && property.GetMethod != null + && !property.GetMethod.IsStatic + && property.Parameters.Length == 1 + && property.Parameters[0].Type.TypeKind == TypeKind.Enum); + if (indexer is not null) { // Use MetadataName because for indexers with [IndexerName("CustomName")], the // Name property is "this[]" but MetadataName is "CustomName" which is what // PropertyChanged events use (e.g., "CustomName[3]" not "this[][3]") + // If the indexer parameter is an enum, use the fully qualified enum member wrapped in EnumIndex + if (indexer.Parameters[0].Type.TypeKind == TypeKind.Enum) + { + var enumType = indexer.Parameters[0].Type; + var enumMember = enumType.GetMembers() + .OfType() + .FirstOrDefault(f => f.IsStatic && f.Name == indexArg); + if (enumMember != null) + { + index = new EnumIndex($"{enumType.ToFQDisplayString()}.{indexArg}"); + } + else + { + _context.ReportDiagnostic(Diagnostic.Create(Descriptors.BindingPropertyNotFound, GetLocation(_node), indexArg, enumType.ToFQDisplayString())); + return false; + } + } + var actualIndexerName = indexer.MetadataName; IPathPart indexAccess = new IndexAccess(actualIndexerName, index, indexer.Type.IsValueType); if (previousPartIsNullable) diff --git a/src/Controls/tests/SourceGen.UnitTests/Maui13856Tests.cs b/src/Controls/tests/SourceGen.UnitTests/Maui13856Tests.cs new file mode 100644 index 000000000000..bdf94420257d --- /dev/null +++ b/src/Controls/tests/SourceGen.UnitTests/Maui13856Tests.cs @@ -0,0 +1,87 @@ +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.Maui.Controls.SourceGen; +using Xunit; + +using static Microsoft.Maui.Controls.Xaml.UnitTests.SourceGen.SourceGeneratorDriver; + +namespace Microsoft.Maui.Controls.Xaml.UnitTests.SourceGen; + +public class Maui13856Tests : SourceGenTestsBase +{ + private record AdditionalXamlFile(string Path, string Content, string? RelativePath = null, string? TargetPath = null, string? ManifestResourceName = null, string? TargetFramework = null, string? NoWarn = null) + : AdditionalFile(Text: SourceGeneratorDriver.ToAdditionalText(Path, Content), Kind: "Xaml", RelativePath: RelativePath ?? Path, TargetPath: TargetPath, ManifestResourceName: ManifestResourceName, TargetFramework: TargetFramework, NoWarn: NoWarn); + + [Fact] + public void DictionaryWithEnumKeyBindingDoesNotCauseErrors() + { + // https://github.com/dotnet/maui/issues/13856 + // Binding to Dictionary with x:DataType should not cause generator errors + // Note: SourceGen currently falls back to runtime binding for dictionary indexers (both string and enum keys) + // This test verifies that enum key bindings don't cause errors in the generator + + var codeBehind = +""" +using System.Collections.Generic; +using Microsoft.Maui.Controls; + +namespace Test +{ + public enum UserSetting + { + BrowserInvisible, + GlobalWaitForElementsInBrowserInSek, + TBD, + } + + public partial class TestPage : ContentPage + { + public TestPage() + { + UserSettings = new Dictionary + { + { UserSetting.TBD, "test value" } + }; + InitializeComponent(); + } + + public Dictionary UserSettings { get; set; } + } +} +"""; + + var xaml = +""" + + + + +"""; + + var compilation = CreateMauiCompilation(); + compilation = compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(codeBehind)); + + var result = RunGenerator(compilation, new AdditionalXamlFile("Test.xaml", xaml)); + + // The generator should not produce any errors + var errors = result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + Assert.Empty(errors); + + // Verify that a source file was generated + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .FirstOrDefault(s => s.HintName.Contains("xsg.cs", System.StringComparison.Ordinal)); + + Assert.True(generatedSource.SourceText != null, "Expected generated source file with xsg.cs extension"); + var generatedCode = generatedSource.SourceText.ToString(); + + // Verify the enum index uses the fully qualified enum member name + Assert.Contains("UserSettings[global::Test.UserSetting.TBD]", generatedCode, System.StringComparison.Ordinal); + } +} diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui13856.rt.xaml b/src/Controls/tests/Xaml.UnitTests/Issues/Maui13856.rt.xaml new file mode 100644 index 000000000000..7b343a501701 --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui13856.rt.xaml @@ -0,0 +1,9 @@ + + + + diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui13856.xaml.cs b/src/Controls/tests/Xaml.UnitTests/Issues/Maui13856.xaml.cs new file mode 100644 index 000000000000..85c6437a5256 --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui13856.xaml.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Maui.Controls.Xaml.UnitTests; + +public enum Maui13856UserSetting +{ +BrowserInvisible, +GlobalWaitForElementsInBrowserInSek, +TBD, +} + +public partial class Maui13856 : ContentPage +{ +public Maui13856() +{ +InitializeComponent(); +} + +public Dictionary UserSettings { get; set; } = new Dictionary +{ +{ Maui13856UserSetting.TBD, "test value" } +}; + +[Collection("Issue")] +public class Tests +{ +[Theory] +[XamlInflatorData] +internal void DictionaryWithEnumKeyBinding(XamlInflator inflator) +{ +// https://github.com/dotnet/maui/issues/13856 +// Binding to Dictionary with x:DataType should compile and work +// .rt.xaml files are runtime-only (no XamlC or SourceGen code generated) +if (inflator != XamlInflator.Runtime) +return; + +var page = new Maui13856(inflator); +page.BindingContext = page; + +Assert.Equal("test value", page.entry.Text); +} +} +}