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 @@ -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)
Expand All @@ -399,6 +418,10 @@ void SetupPart(TypeInfo sourceType, BindingExpressionPart part)
catch (OverflowException)
{
}
catch (ArgumentException)
{
// Enum.Parse throws ArgumentException for invalid enum values
}
}
}
}
Expand Down
37 changes: 37 additions & 0 deletions src/Controls/src/SourceGen/CompiledBindingMarkup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IFieldSymbol>()
.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)
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 enum index uses the fully qualified enum member name
Assert.Contains("UserSettings[global::Test.UserSetting.TBD]", generatedCode, System.StringComparison.Ordinal);
}
}
9 changes: 9 additions & 0 deletions src/Controls/tests/Xaml.UnitTests/Issues/Maui13856.rt.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>
44 changes: 44 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,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<Maui13856UserSetting, object> UserSettings { get; set; } = new Dictionary<Maui13856UserSetting, object>
{
{ 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<CustomEnum, object> 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);
}
}
}
Loading