Skip to content

Commit 6f6407e

Browse files
Fix NullReferenceException when binding to Dictionary<Enum, object> with x:DataType
Fixes #13856 ## Problem Using with a binding to like caused a NullReferenceException at compile time in XamlC. ## Solution - **XamlC (SetPropertiesVisitor.cs)**: Added support for finding indexers with enum parameter types and emitting enum constant values via IL - **SourceGen (CompiledBindingMarkup.cs)**: Added support for enum indexer parameter types in the binding path parser ## Changes - Added enum indexer detection in XamlC's property digger - Updated type validation to allow enum indexer types - Added code to emit enum constant values as IL instructions - Added enum indexer support in SourceGen's TryParsePath method - Fixed pre-existing test failure in Maui32879Tests (unrelated pragma warning directive) ## Tests - Added Maui13856.xaml unit test for XamlC compilation - Added Maui13856Tests.cs for SourceGen validation
1 parent 34a9faa commit 6f6407e

File tree

10 files changed

+226
-7
lines changed

10 files changed

+226
-7
lines changed

src/Controls/src/BindingSourceGen/AccessExpressionBuilder.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public static string ExtendExpression(string previousExpression, IPathPart nextP
1515
Cast { TargetType: var targetType } => $"({previousExpression} as {CastTargetName(targetType)})",
1616
ConditionalAccess conditionalAccess => ExtendExpression(previousExpression: $"{previousExpression}?", conditionalAccess.Part),
1717
IndexAccess { Index: int numericIndex } => $"{previousExpression}[{numericIndex}]",
18+
IndexAccess { Index: EnumIndex enumIndex } => $"{previousExpression}[{enumIndex.FullyQualifiedEnumValue}]",
1819
IndexAccess { Index: string stringIndex } => $"{previousExpression}[\"{stringIndex}\"]",
1920
MemberAccess { Kind: AccessorKind.Field, IsGetterInaccessible: true } memberAccess => $"{CreateUnsafeFieldAccessorMethodName(memberAccess.MemberName)}({previousExpression})",
2021
MemberAccess { Kind: AccessorKind.Property, IsGetterInaccessible: true } memberAccess => $"{CreateUnsafePropertyAccessorGetMethodName(memberAccess.MemberName)}({previousExpression})",

src/Controls/src/BindingSourceGen/PathPart.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ public bool Equals(IPathPart other)
7474
}
7575
}
7676

77+
/// <summary>
78+
/// Represents an enum value used as an index in a binding path.
79+
/// This is distinct from string indices because the generated code should not quote the value.
80+
/// </summary>
81+
public sealed record EnumIndex(string FullyQualifiedEnumValue)
82+
{
83+
public override string ToString() => FullyQualifiedEnumValue;
84+
}
85+
7786
public sealed record ConditionalAccess(IPathPart Part) : IPathPart
7887
{
7988
public string? PropertyName => Part.PropertyName;

src/Controls/src/BindingSourceGen/Setter.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public static string BuildAssignmentStatement(string accessAccumulator, IPathPar
7373
IndexAccess indexAccess => indexAccess.Index switch
7474
{
7575
int numericIndex => $"{accessAccumulator}[{numericIndex}] = {assignedValueExpression};",
76+
EnumIndex enumIndex => $"{accessAccumulator}[{enumIndex.FullyQualifiedEnumValue}] = {assignedValueExpression};",
7677
string stringIndex => $"{accessAccumulator}[\"{stringIndex}\"] = {assignedValueExpression};",
7778
_ => throw new NotSupportedException($"Unsupported index type: {indexAccess.Index.GetType()}"),
7879
},

src/Controls/src/Build.Tasks/SetPropertiesVisitor.cs

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -720,12 +720,21 @@ static bool TryParsePath(ILContext context, string path, TypeReference tSourceRe
720720
&& pd.GetMethod != null
721721
&& TypeRefComparer.Default.Equals(pd.GetMethod.Parameters[0].ParameterType.ResolveGenericParameters(previousPartTypeRef), module.ImportReference(context.Cache, ("mscorlib", "System", "Object")))
722722
&& pd.GetMethod.IsPublic, out indexerDeclTypeRef);
723+
// Try to find an indexer with an enum parameter type
724+
indexer ??= previousPartTypeRef.GetProperty(context.Cache,
725+
pd => pd.Name == indexerName
726+
&& pd.GetMethod != null
727+
&& pd.GetMethod.Parameters[0].ParameterType.ResolveGenericParameters(previousPartTypeRef).ResolveCached(context.Cache)?.IsEnum == true
728+
&& pd.GetMethod.IsPublic, out indexerDeclTypeRef);
723729

724730
properties.Add((indexer, indexerDeclTypeRef, indexArg));
725731
if (indexer != null) //the case when we index on an array, not a list
726732
{
727733
var indexType = indexer.GetMethod.Parameters[0].ParameterType.ResolveGenericParameters(indexerDeclTypeRef);
728-
if (!TypeRefComparer.Default.Equals(indexType, module.TypeSystem.String) && !TypeRefComparer.Default.Equals(indexType, module.TypeSystem.Int32))
734+
var indexTypeDef = indexType.ResolveCached(context.Cache);
735+
if (!TypeRefComparer.Default.Equals(indexType, module.TypeSystem.String)
736+
&& !TypeRefComparer.Default.Equals(indexType, module.TypeSystem.Int32)
737+
&& indexTypeDef?.IsEnum != true)
729738
throw new BuildException(BindingIndexerTypeUnsupported, lineInfo, null, indexType.FullName);
730739
previousPartTypeRef = indexer.PropertyType.ResolveGenericParameters(indexerDeclTypeRef);
731740
}
@@ -743,7 +752,7 @@ static bool TryParsePath(ILContext context, string path, TypeReference tSourceRe
743752
return true;
744753
}
745754

746-
static IEnumerable<Instruction> DigProperties(IEnumerable<(PropertyDefinition property, TypeReference propDeclTypeRef, string indexArg)> properties, Dictionary<TypeReference, VariableDefinition> locs, Func<Instruction> fallback, IXmlLineInfo lineInfo, ModuleDefinition module)
755+
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)
747756
{
748757
var first = true;
749758

@@ -781,7 +790,25 @@ static IEnumerable<Instruction> DigProperties(IEnumerable<(PropertyDefinition pr
781790
else if (TypeRefComparer.Default.Equals(indexType, module.TypeSystem.Int32) && int.TryParse(indexArg, out index))
782791
yield return Create(Ldc_I4, index);
783792
else
784-
throw new BuildException(BindingIndexerParse, lineInfo, null, indexArg, property.Name);
793+
{
794+
// Try to handle enum types
795+
var indexTypeDef = cache != null ? indexType.ResolveCached(cache) : indexType.Resolve();
796+
if (indexTypeDef?.IsEnum == true)
797+
{
798+
// Find the enum field with the matching name
799+
var enumField = indexTypeDef.Fields.FirstOrDefault(f => f.IsStatic && f.Name == indexArg);
800+
if (enumField != null)
801+
{
802+
// Load the enum value as an integer constant
803+
var enumValue = Convert.ToInt32(enumField.Constant);
804+
yield return Create(Ldc_I4, enumValue);
805+
}
806+
else
807+
throw new BuildException(BindingIndexerParse, lineInfo, null, indexArg, property.Name);
808+
}
809+
else
810+
throw new BuildException(BindingIndexerParse, lineInfo, null, indexArg, property.Name);
811+
}
785812
}
786813
}
787814

@@ -860,7 +887,7 @@ static IEnumerable<Instruction> CompiledBindingGetGetter(TypeReference tSourceRe
860887
pop = Create(Pop);
861888

862889
return pop;
863-
}, node as IXmlLineInfo, module));
890+
}, node as IXmlLineInfo, module, context.Cache));
864891

865892
foreach (var loc in locs.Values)
866893
getter.Body.Variables.Add(loc);
@@ -950,7 +977,7 @@ static IEnumerable<Instruction> CompiledBindingGetSetter(TypeReference tSourceRe
950977
pop = Create(Pop);
951978

952979
return pop;
953-
}, node as IXmlLineInfo, module));
980+
}, node as IXmlLineInfo, module, context.Cache));
954981

955982
foreach (var loc in locs.Values)
956983
setter.Body.Variables.Add(loc);
@@ -1076,7 +1103,7 @@ static IEnumerable<Instruction> CompiledBindingGetHandlers(TypeReference tSource
10761103
il.Emit(Ldarg_0);
10771104
var lastGetterTypeRef = properties[i - 1].property?.PropertyType;
10781105
var locs = new Dictionary<TypeReference, VariableDefinition>();
1079-
il.Append(DigProperties(properties.Take(i), locs, null, node as IXmlLineInfo, module));
1106+
il.Append(DigProperties(properties.Take(i), locs, null, node as IXmlLineInfo, module, context.Cache));
10801107
foreach (var loc in locs.Values)
10811108
partGetter.Body.Variables.Add(loc);
10821109
if (lastGetterTypeRef != null && lastGetterTypeRef.IsValueType)

src/Controls/src/Core/BindingExpression.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,16 @@ PropertyInfo GetIndexer(TypeInfo sourceType, string indexerName, string content)
293293
return pi;
294294
}
295295

296+
//try to find an indexer taking an enum that matches the content
297+
foreach (var pi in sourceType.DeclaredProperties)
298+
{
299+
if (pi.Name != indexerName)
300+
continue;
301+
var paramType = pi.CanRead ? pi.GetMethod.GetParameters()[0].ParameterType : null;
302+
if (paramType != null && paramType.IsEnum && Enum.IsDefined(paramType, content))
303+
return pi;
304+
}
305+
296306
//try to fallback to an object indexer
297307
foreach (var pi in sourceType.DeclaredProperties)
298308
{
@@ -365,7 +375,16 @@ void SetupPart(TypeInfo sourceType, BindingExpressionPart part)
365375
{
366376
try
367377
{
368-
object arg = Convert.ChangeType(part.Content, parameter.ParameterType, CultureInfo.InvariantCulture);
378+
object arg;
379+
if (parameter.ParameterType.IsEnum)
380+
{
381+
// Handle enum types - parse the string to enum
382+
arg = Enum.Parse(parameter.ParameterType, part.Content);
383+
}
384+
else
385+
{
386+
arg = Convert.ChangeType(part.Content, parameter.ParameterType, CultureInfo.InvariantCulture);
387+
}
369388
part.Arguments = new[] { arg };
370389
}
371390
catch (FormatException)
@@ -377,6 +396,10 @@ void SetupPart(TypeInfo sourceType, BindingExpressionPart part)
377396
catch (OverflowException)
378397
{
379398
}
399+
catch (ArgumentException)
400+
{
401+
// Enum.Parse throws ArgumentException for invalid enum values
402+
}
380403
}
381404
}
382405
}

src/Controls/src/SourceGen/CompiledBindingMarkup.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,8 +378,30 @@ bool TryParsePath(
378378
&& property.Parameters.Length == 1
379379
&& property.Parameters[0].Type.SpecialType == SpecialType.System_Object);
380380

381+
// Try to find an indexer with an enum parameter type
382+
indexer ??= previousPartType
383+
.GetAllProperties(indexerName, _context)
384+
.FirstOrDefault(property =>
385+
property.GetMethod != null
386+
&& !property.GetMethod.IsStatic
387+
&& property.Parameters.Length == 1
388+
&& property.Parameters[0].Type.TypeKind == TypeKind.Enum);
389+
381390
if (indexer is not null)
382391
{
392+
// If the indexer parameter is an enum, use the fully qualified enum member wrapped in EnumIndex
393+
if (indexer.Parameters[0].Type.TypeKind == TypeKind.Enum)
394+
{
395+
var enumType = indexer.Parameters[0].Type;
396+
var enumMember = enumType.GetMembers()
397+
.OfType<IFieldSymbol>()
398+
.FirstOrDefault(f => f.IsStatic && f.Name == indexArg);
399+
if (enumMember != null)
400+
{
401+
index = new EnumIndex($"{enumType.ToFQDisplayString()}.{indexArg}");
402+
}
403+
}
404+
383405
var indexAccess = new IndexAccess(indexerName, index, indexer.Type.IsValueType);
384406
bindingPathParts.Add(indexAccess);
385407

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System.Linq;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.Maui.Controls.SourceGen;
5+
using Xunit;
6+
7+
using static Microsoft.Maui.Controls.Xaml.UnitTests.SourceGen.SourceGeneratorDriver;
8+
9+
namespace Microsoft.Maui.Controls.Xaml.UnitTests.SourceGen;
10+
11+
public class Maui13856Tests : SourceGenTestsBase
12+
{
13+
private record AdditionalXamlFile(string Path, string Content, string? RelativePath = null, string? TargetPath = null, string? ManifestResourceName = null, string? TargetFramework = null, string? NoWarn = null)
14+
: AdditionalFile(Text: SourceGeneratorDriver.ToAdditionalText(Path, Content), Kind: "Xaml", RelativePath: RelativePath ?? Path, TargetPath: TargetPath, ManifestResourceName: ManifestResourceName, TargetFramework: TargetFramework, NoWarn: NoWarn);
15+
16+
[Fact]
17+
public void DictionaryWithEnumKeyBindingDoesNotCauseErrors()
18+
{
19+
// https://github.com/dotnet/maui/issues/13856
20+
// Binding to Dictionary<CustomEnum, object> with x:DataType should not cause generator errors
21+
// Note: SourceGen currently falls back to runtime binding for dictionary indexers (both string and enum keys)
22+
// This test verifies that enum key bindings don't cause errors in the generator
23+
24+
var codeBehind =
25+
"""
26+
using System.Collections.Generic;
27+
using Microsoft.Maui.Controls;
28+
29+
namespace Test
30+
{
31+
public enum UserSetting
32+
{
33+
BrowserInvisible,
34+
GlobalWaitForElementsInBrowserInSek,
35+
TBD,
36+
}
37+
38+
public partial class TestPage : ContentPage
39+
{
40+
public TestPage()
41+
{
42+
UserSettings = new Dictionary<UserSetting, object>
43+
{
44+
{ UserSetting.TBD, "test value" }
45+
};
46+
InitializeComponent();
47+
}
48+
49+
public Dictionary<UserSetting, object> UserSettings { get; set; }
50+
}
51+
}
52+
""";
53+
54+
var xaml =
55+
"""
56+
<?xml version="1.0" encoding="UTF-8"?>
57+
<ContentPage
58+
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
59+
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
60+
xmlns:local="clr-namespace:Test"
61+
x:Class="Test.TestPage"
62+
x:DataType="local:TestPage">
63+
<Entry x:Name="entry" Text="{Binding UserSettings[TBD]}" />
64+
</ContentPage>
65+
""";
66+
67+
var compilation = CreateMauiCompilation();
68+
compilation = compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(codeBehind));
69+
70+
var result = RunGenerator<XamlGenerator>(compilation, new AdditionalXamlFile("Test.xaml", xaml));
71+
72+
// The generator should not produce any errors
73+
var errors = result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList();
74+
Assert.Empty(errors);
75+
76+
// Verify that a source file was generated
77+
var generatedSource = result.Results
78+
.SelectMany(r => r.GeneratedSources)
79+
.FirstOrDefault(s => s.HintName.Contains("xsg.cs", System.StringComparison.Ordinal));
80+
81+
Assert.True(generatedSource.SourceText != null, "Expected generated source file with xsg.cs extension");
82+
var generatedCode = generatedSource.SourceText.ToString();
83+
84+
// Verify the binding path is in the generated code (even if using runtime binding fallback)
85+
Assert.Contains("UserSettings[TBD]", generatedCode, System.StringComparison.Ordinal);
86+
}
87+
}

src/Controls/tests/SourceGen.UnitTests/Maui32879Tests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public TestPage()
6060
// </auto-generated>
6161
//------------------------------------------------------------------------------
6262
#nullable enable
63+
#pragma warning disable CS0219 // Variable is assigned but its value is never used
6364
6465
namespace Test;
6566
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<ContentPage
3+
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
4+
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
5+
xmlns:local="using:Microsoft.Maui.Controls.Xaml.UnitTests"
6+
x:Class="Microsoft.Maui.Controls.Xaml.UnitTests.Maui13856"
7+
x:DataType="local:Maui13856">
8+
<Entry x:Name="entry" Text="{Binding UserSettings[TBD]}" />
9+
</ContentPage>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using System.Collections.Generic;
2+
using NUnit.Framework;
3+
4+
namespace Microsoft.Maui.Controls.Xaml.UnitTests;
5+
6+
public enum Maui13856UserSetting
7+
{
8+
BrowserInvisible,
9+
GlobalWaitForElementsInBrowserInSek,
10+
TBD,
11+
}
12+
13+
public partial class Maui13856 : ContentPage
14+
{
15+
public Maui13856()
16+
{
17+
InitializeComponent();
18+
}
19+
20+
public Dictionary<Maui13856UserSetting, object> UserSettings { get; set; } = new Dictionary<Maui13856UserSetting, object>
21+
{
22+
{ Maui13856UserSetting.TBD, "test value" }
23+
};
24+
25+
[TestFixture]
26+
class Tests
27+
{
28+
[Test]
29+
public void DictionaryWithEnumKeyBinding([Values] XamlInflator inflator)
30+
{
31+
// https://github.com/dotnet/maui/issues/13856
32+
// Binding to Dictionary<CustomEnum, object> with x:DataType should compile and work
33+
var page = new Maui13856(inflator);
34+
page.BindingContext = page;
35+
36+
Assert.That(page.entry.Text, Is.EqualTo("test value"));
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)