Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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})",
Expand Down
9 changes: 9 additions & 0 deletions src/Controls/src/BindingSourceGen/PathPart.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ public bool Equals(IPathPart other)
}
}

/// <summary>
/// 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.
/// </summary>
public sealed record EnumIndex(string FullyQualifiedEnumValue)
{
public override string ToString() => FullyQualifiedEnumValue;
}

public sealed record ConditionalAccess(IPathPart Part) : IPathPart
{
public string? PropertyName => Part.PropertyName;
Expand Down
1 change: 1 addition & 0 deletions src/Controls/src/BindingSourceGen/Setter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()}"),
},
Expand Down
39 changes: 33 additions & 6 deletions src/Controls/src/Build.Tasks/SetPropertiesVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -743,7 +752,7 @@ static bool TryParsePath(ILContext context, string path, TypeReference tSourceRe
return true;
}

static IEnumerable<Instruction> DigProperties(IEnumerable<(PropertyDefinition property, TypeReference propDeclTypeRef, string indexArg)> properties, Dictionary<TypeReference, VariableDefinition> locs, Func<Instruction> fallback, IXmlLineInfo lineInfo, ModuleDefinition module)
static IEnumerable<Instruction> DigProperties(IEnumerable<(PropertyDefinition property, TypeReference propDeclTypeRef, string indexArg)> properties, Dictionary<TypeReference, VariableDefinition> locs, Func<Instruction> fallback, IXmlLineInfo lineInfo, ModuleDefinition module, XamlCache cache = null)
{
var first = true;

Expand Down Expand Up @@ -781,7 +790,25 @@ static IEnumerable<Instruction> 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);
}
}
}

Expand Down Expand Up @@ -860,7 +887,7 @@ static IEnumerable<Instruction> 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);
Expand Down Expand Up @@ -950,7 +977,7 @@ static IEnumerable<Instruction> 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);
Expand Down Expand Up @@ -1076,7 +1103,7 @@ static IEnumerable<Instruction> CompiledBindingGetHandlers(TypeReference tSource
il.Emit(Ldarg_0);
var lastGetterTypeRef = properties[i - 1].property?.PropertyType;
var locs = new Dictionary<TypeReference, VariableDefinition>();
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)
Expand Down
25 changes: 24 additions & 1 deletion src/Controls/src/Core/BindingExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -365,7 +375,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)
Expand All @@ -377,6 +396,10 @@ void SetupPart(TypeInfo sourceType, BindingExpressionPart part)
catch (OverflowException)
{
}
catch (ArgumentException)
{
// Enum.Parse throws ArgumentException for invalid enum values
}
}
}
}
Expand Down
22 changes: 22 additions & 0 deletions src/Controls/src/SourceGen/CompiledBindingMarkup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -378,8 +378,30 @@ 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);

if (indexer is not null)
{
// 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<IFieldSymbol>()
.FirstOrDefault(f => f.IsStatic && f.Name == indexArg);
if (enumMember != null)
{
index = new EnumIndex($"{enumType.ToFQDisplayString()}.{indexArg}");
}
Copy link

Copilot AI Nov 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When an enum indexer is found but the enum member doesn't exist (enumMember is null), the code silently continues without updating the index variable. This means it will use the original indexArg string value instead of a fully qualified enum member reference, which could lead to incorrect code generation.

Consider adding an else clause to handle the case when the enum member is not found:

if (enumMember != null)
{
    index = $"{enumType.ToFQDisplayString()}.{indexArg}";
}
else
{
    // Log a diagnostic or return false to indicate parsing failure
    return false;
}
Suggested change
}
}
else
{
return false; // Enum member not found, fail parsing
}

Copilot uses AI. Check for mistakes.
}

var indexAccess = new IndexAccess(indexerName, index, indexer.Type.IsValueType);
bindingPathParts.Add(indexAccess);

Expand Down
87 changes: 87 additions & 0 deletions src/Controls/tests/SourceGen.UnitTests/Maui13856Tests.cs
Original file line number Diff line number Diff line change
@@ -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<CustomEnum, object> 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, object>
{
{ UserSetting.TBD, "test value" }
};
InitializeComponent();
}
public Dictionary<UserSetting, object> UserSettings { get; set; }
}
}
""";

var xaml =
"""
<?xml version="1.0" encoding="UTF-8"?>
<ContentPage
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Test"
x:Class="Test.TestPage"
x:DataType="local:TestPage">
<Entry x:Name="entry" Text="{Binding UserSettings[TBD]}" />
</ContentPage>
""";

var compilation = CreateMauiCompilation();
compilation = compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(codeBehind));

var result = RunGenerator<XamlGenerator>(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 binding path is in the generated code (even if using runtime binding fallback)
Assert.Contains("UserSettings[TBD]", generatedCode, System.StringComparison.Ordinal);
Comment on lines +84 to +85
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this assert should be here. Ideally we'll drop the unnecessary markup extension instantiation at some point (#31614). I think this assert should look for .UserSettings[global::Test.UserSetting.TBD] instead.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(although that might currently fail due to #32905)

}
}
1 change: 1 addition & 0 deletions src/Controls/tests/SourceGen.UnitTests/Maui32879Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public TestPage()
// </auto-generated>
//------------------------------------------------------------------------------
#nullable enable
#pragma warning disable CS0219 // Variable is assigned but its value is never used

namespace Test;

Expand Down
9 changes: 9 additions & 0 deletions src/Controls/tests/Xaml.UnitTests/Issues/Maui13856.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<ContentPage
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="using:Microsoft.Maui.Controls.Xaml.UnitTests"
x:Class="Microsoft.Maui.Controls.Xaml.UnitTests.Maui13856"
x:DataType="local:Maui13856">
<Entry x:Name="entry" Text="{Binding UserSettings[TBD]}" />
</ContentPage>
39 changes: 39 additions & 0 deletions src/Controls/tests/Xaml.UnitTests/Issues/Maui13856.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Collections.Generic;
using NUnit.Framework;

namespace Microsoft.Maui.Controls.Xaml.UnitTests;

public enum Maui13856UserSetting
{
BrowserInvisible,
GlobalWaitForElementsInBrowserInSek,
TBD,
}

public partial class Maui13856 : ContentPage
{
public Maui13856()
{
InitializeComponent();
}

public Dictionary<Maui13856UserSetting, object> UserSettings { get; set; } = new Dictionary<Maui13856UserSetting, object>
{
{ Maui13856UserSetting.TBD, "test value" }
};

[TestFixture]
class Tests
{
[Test]
public void DictionaryWithEnumKeyBinding([Values] XamlInflator inflator)
{
// https://github.com/dotnet/maui/issues/13856
// Binding to Dictionary<CustomEnum, object> with x:DataType should compile and work
var page = new Maui13856(inflator);
page.BindingContext = page;

Assert.That(page.entry.Text, Is.EqualTo("test value"));
}
}
}
Loading