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