diff --git a/Microsoft.Maui-dev.sln b/Microsoft.Maui-dev.sln
index 6f63284ea8d6..98ba3d73220e 100644
--- a/Microsoft.Maui-dev.sln
+++ b/Microsoft.Maui-dev.sln
@@ -1,4 +1,4 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
+Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31410.414
MinimumVisualStudioVersion = 10.0.40219.1
@@ -255,6 +255,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Controls.TestCases.Mac.Test
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Controls.TestCases.WinUI.Tests", "src\Controls\tests\TestCases.WinUI.Tests\Controls.TestCases.WinUI.Tests.csproj", "{A3E22F99-F380-4005-8483-3ACA6C104220}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Controls.BindingSourceGen", "src\Controls\src\BindingSourceGen\Controls.BindingSourceGen.csproj", "{9538341F-8A00-4356-A2B2-5C2959979F22}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Controls.BindingSourceGen.UnitTests", "src\Controls\tests\BindingSourceGen.UnitTests\Controls.BindingSourceGen.UnitTests.csproj", "{23FEFC89-5D2F-491C-BBE0-0E73AFD8BA47}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -648,6 +652,14 @@ Global
{A3E22F99-F380-4005-8483-3ACA6C104220}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A3E22F99-F380-4005-8483-3ACA6C104220}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A3E22F99-F380-4005-8483-3ACA6C104220}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9538341F-8A00-4356-A2B2-5C2959979F22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9538341F-8A00-4356-A2B2-5C2959979F22}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9538341F-8A00-4356-A2B2-5C2959979F22}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9538341F-8A00-4356-A2B2-5C2959979F22}.Release|Any CPU.Build.0 = Release|Any CPU
+ {23FEFC89-5D2F-491C-BBE0-0E73AFD8BA47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {23FEFC89-5D2F-491C-BBE0-0E73AFD8BA47}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {23FEFC89-5D2F-491C-BBE0-0E73AFD8BA47}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {23FEFC89-5D2F-491C-BBE0-0E73AFD8BA47}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -766,6 +778,8 @@ Global
{5DDA6439-CDE0-4BFE-8BF9-77962BC69ACA} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
{6E1ADE49-680E-4CA3-8FEA-6450802F8250} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
{A3E22F99-F380-4005-8483-3ACA6C104220} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
+ {9538341F-8A00-4356-A2B2-5C2959979F22} = {50C758FE-4E10-409A-94F5-A75480960864}
+ {23FEFC89-5D2F-491C-BBE0-0E73AFD8BA47} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0B8ABEAD-D2B5-4370-A187-62B5ABE4EE50}
diff --git a/Microsoft.Maui-vscode.sln b/Microsoft.Maui-vscode.sln
index 185fbed2c521..0778b1083b69 100644
--- a/Microsoft.Maui-vscode.sln
+++ b/Microsoft.Maui-vscode.sln
@@ -1,4 +1,4 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
+Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31410.414
MinimumVisualStudioVersion = 10.0.40219.1
@@ -225,6 +225,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Controls.TestCases.Mac.Test
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Controls.TestCases.WinUI.Tests", "src\Controls\tests\TestCases.WinUI.Tests\Controls.TestCases.WinUI.Tests.csproj", "{DACF87DB-6354-4B18-A34C-923A55F317F0}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Controls.BindingSourceGen", "src\Controls\src\BindingSourceGen\Controls.BindingSourceGen.csproj", "{F3E4596C-3047-42F8-9724-80BCE74C141C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Controls.BindingSourceGen.UnitTests", "src\Controls\tests\BindingSourceGen.UnitTests\Controls.BindingSourceGen.UnitTests.csproj", "{0048EA9A-D751-4576-A2BB-2A37BFB385A5}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -558,6 +562,14 @@ Global
{DACF87DB-6354-4B18-A34C-923A55F317F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DACF87DB-6354-4B18-A34C-923A55F317F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DACF87DB-6354-4B18-A34C-923A55F317F0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F3E4596C-3047-42F8-9724-80BCE74C141C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F3E4596C-3047-42F8-9724-80BCE74C141C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F3E4596C-3047-42F8-9724-80BCE74C141C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F3E4596C-3047-42F8-9724-80BCE74C141C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0048EA9A-D751-4576-A2BB-2A37BFB385A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0048EA9A-D751-4576-A2BB-2A37BFB385A5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0048EA9A-D751-4576-A2BB-2A37BFB385A5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0048EA9A-D751-4576-A2BB-2A37BFB385A5}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -661,6 +673,8 @@ Global
{AF5A8B2E-13E7-4D94-A44E-BC96180C1FCC} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
{0B3AF328-82B8-431D-8AFF-45F0F86CEA0E} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
{DACF87DB-6354-4B18-A34C-923A55F317F0} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
+ {F3E4596C-3047-42F8-9724-80BCE74C141C} = {50C758FE-4E10-409A-94F5-A75480960864}
+ {0048EA9A-D751-4576-A2BB-2A37BFB385A5} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0B8ABEAD-D2B5-4370-A187-62B5ABE4EE50}
diff --git a/Microsoft.Maui.sln b/Microsoft.Maui.sln
index 934382686cc2..7b0a1c3767a0 100644
--- a/Microsoft.Maui.sln
+++ b/Microsoft.Maui.sln
@@ -1,4 +1,4 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
+Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31410.414
MinimumVisualStudioVersion = 10.0.40219.1
@@ -275,6 +275,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Controls.TestCases.Mac.Test
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Controls.TestCases.WinUI.Tests", "src\Controls\tests\TestCases.WinUI.Tests\Controls.TestCases.WinUI.Tests.csproj", "{A1D6B9E5-D8FF-436A-9ACF-703CA5E4BD02}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Controls.BindingSourceGen", "src\Controls\src\BindingSourceGen\Controls.BindingSourceGen.csproj", "{83C5E677-A9C8-4E46-B72C-CAF04DC5D3D5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Controls.BindingSourceGen.UnitTests", "src\Controls\tests\BindingSourceGen.UnitTests\Controls.BindingSourceGen.UnitTests.csproj", "{6AEE83CC-08CA-466A-BA86-774BE88A541B}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -700,6 +704,14 @@ Global
{A1D6B9E5-D8FF-436A-9ACF-703CA5E4BD02}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1D6B9E5-D8FF-436A-9ACF-703CA5E4BD02}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1D6B9E5-D8FF-436A-9ACF-703CA5E4BD02}.Release|Any CPU.Build.0 = Release|Any CPU
+ {83C5E677-A9C8-4E46-B72C-CAF04DC5D3D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {83C5E677-A9C8-4E46-B72C-CAF04DC5D3D5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {83C5E677-A9C8-4E46-B72C-CAF04DC5D3D5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {83C5E677-A9C8-4E46-B72C-CAF04DC5D3D5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6AEE83CC-08CA-466A-BA86-774BE88A541B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6AEE83CC-08CA-466A-BA86-774BE88A541B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6AEE83CC-08CA-466A-BA86-774BE88A541B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6AEE83CC-08CA-466A-BA86-774BE88A541B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -828,6 +840,8 @@ Global
{29115330-6854-4715-B382-18EA3A8A8731} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
{E8CAE2B6-62B3-431C-A76D-1CCD961A1FB4} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
{A1D6B9E5-D8FF-436A-9ACF-703CA5E4BD02} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
+ {83C5E677-A9C8-4E46-B72C-CAF04DC5D3D5} = {50C758FE-4E10-409A-94F5-A75480960864}
+ {6AEE83CC-08CA-466A-BA86-774BE88A541B} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0B8ABEAD-D2B5-4370-A187-62B5ABE4EE50}
diff --git a/THIRD-PARTY-NOTICES.TXT b/THIRD-PARTY-NOTICES.TXT
index b93840c3b5c2..3ea5936c902a 100644
--- a/THIRD-PARTY-NOTICES.TXT
+++ b/THIRD-PARTY-NOTICES.TXT
@@ -521,3 +521,63 @@ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
=========================================
+
+
+License notice for .NET Community Toolkit
+=========================================
+
+(https://github.com/CommunityToolkit/dotnet/blob/main/License.md)
+
+Copyright (c) .NET Foundation and Contributors
+
+All rights reserved.
+
+MIT License (MIT)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+=========================================
+
+
+License notice for BenchmarkDotNet
+=========================================
+
+(https://github.com/dotnet/BenchmarkDotNet/blob/master/LICENSE.md)
+
+The MIT License (MIT)
+
+Copyright (c) 2013–2024 .NET Foundation and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+=========================================
diff --git a/eng/cake/dotnet.cake b/eng/cake/dotnet.cake
index 66044a51f052..aca0fe1d18ac 100644
--- a/eng/cake/dotnet.cake
+++ b/eng/cake/dotnet.cake
@@ -251,6 +251,7 @@ Task("dotnet-test")
// "**/Controls.Core.Design.UnitTests.csproj",
"**/Controls.Xaml.UnitTests.csproj",
"**/SourceGen.UnitTests.csproj",
+ "**/Controls.BindingSourceGen.UnitTests.csproj",
"**/Core.UnitTests.csproj",
"**/Essentials.UnitTests.csproj",
"**/Resizetizer.UnitTests.csproj",
diff --git a/src/Controls/src/BindingSourceGen/AccessExpressionBuilder.cs b/src/Controls/src/BindingSourceGen/AccessExpressionBuilder.cs
new file mode 100644
index 000000000000..0ec0306556c3
--- /dev/null
+++ b/src/Controls/src/BindingSourceGen/AccessExpressionBuilder.cs
@@ -0,0 +1,18 @@
+namespace Microsoft.Maui.Controls.BindingSourceGen;
+
+public static class AccessExpressionBuilder
+{
+ public static string ExtendExpression(string previousExpression, IPathPart nextPart)
+ => nextPart switch
+ {
+ Cast { TargetType: var targetType } => $"({previousExpression} as {CastTargetName(targetType)})",
+ ConditionalAccess conditionalAccess => ExtendExpression(previousExpression: $"{previousExpression}?", conditionalAccess.Part),
+ IndexAccess { Index: int numericIndex } => $"{previousExpression}[{numericIndex}]",
+ IndexAccess { Index: string stringIndex } => $"{previousExpression}[\"{stringIndex}\"]",
+ MemberAccess memberAccess => $"{previousExpression}.{memberAccess.MemberName}",
+ _ => throw new NotSupportedException($"Unsupported path part type: {nextPart.GetType()}"),
+ };
+
+ private static string CastTargetName(TypeDescription targetType)
+ => targetType.IsValueType ? $"{targetType.GlobalName}?" : targetType.GlobalName;
+}
diff --git a/src/Controls/src/BindingSourceGen/BindingCodeWriter.cs b/src/Controls/src/BindingSourceGen/BindingCodeWriter.cs
new file mode 100644
index 000000000000..a17f20c1f467
--- /dev/null
+++ b/src/Controls/src/BindingSourceGen/BindingCodeWriter.cs
@@ -0,0 +1,348 @@
+using System.CodeDom.Compiler;
+using System.Globalization;
+
+namespace Microsoft.Maui.Controls.BindingSourceGen;
+
+public sealed class BindingCodeWriter
+{
+ public static string GeneratedCodeAttribute => $"[GeneratedCodeAttribute(\"{typeof(BindingCodeWriter).Assembly.FullName}\", \"{typeof(BindingCodeWriter).Assembly.GetName().Version}\")]";
+
+ public string GenerateCode()
+ {
+ if (_bindings.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ return DoGenerateCode();
+ }
+
+ private string DoGenerateCode() => $$"""
+ //------------------------------------------------------------------------------
+ //
+ // This code was generated by a .NET MAUI source generator.
+ //
+ // Changes to this file may cause incorrect behavior and will be lost if
+ // the code is regenerated.
+ //
+ //------------------------------------------------------------------------------
+ #nullable enable
+
+ namespace System.Runtime.CompilerServices
+ {
+ using System;
+ using System.CodeDom.Compiler;
+
+ {{GeneratedCodeAttribute}}
+ [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+ file sealed class InterceptsLocationAttribute : Attribute
+ {
+ public InterceptsLocationAttribute(string filePath, int line, int column)
+ {
+ FilePath = filePath;
+ Line = line;
+ Column = column;
+ }
+
+ public string FilePath { get; }
+ public int Line { get; }
+ public int Column { get; }
+ }
+ }
+
+ namespace Microsoft.Maui.Controls.Generated
+ {
+ using System;
+ using System.CodeDom.Compiler;
+ using System.Runtime.CompilerServices;
+ using Microsoft.Maui.Controls.Internals;
+
+ {{GeneratedCodeAttribute}}
+ file static class GeneratedBindableObjectExtensions
+ {
+ {{GenerateBindingMethods(indent: 2)}}
+
+ private static bool ShouldUseSetter(BindingMode mode, BindableProperty bindableProperty)
+ => mode == BindingMode.OneWayToSource
+ || mode == BindingMode.TwoWay
+ || (mode == BindingMode.Default
+ && (bindableProperty.DefaultBindingMode == BindingMode.OneWayToSource
+ || bindableProperty.DefaultBindingMode == BindingMode.TwoWay));
+ }
+ }
+ """;
+
+ private readonly List _bindings = new();
+
+ public void AddBinding(SetBindingInvocationDescription binding)
+ {
+ if (!binding.NullableContextEnabled)
+ {
+ var referenceTypesConditionalAccessTransformer = new ReferenceTypesConditionalAccessTransformer();
+ binding = referenceTypesConditionalAccessTransformer.Transform(binding);
+ }
+ _bindings.Add(binding);
+ }
+
+ private string GenerateBindingMethods(int indent)
+ {
+ using var builder = new BindingInterceptorCodeBuilder(indent);
+
+ for (int i = 0; i < _bindings.Count; i++)
+ {
+ builder.AppendSetBindingInterceptor(id: i + 1, _bindings[i]);
+ }
+
+ return builder.ToString();
+ }
+
+ public sealed class BindingInterceptorCodeBuilder : IDisposable
+ {
+ private StringWriter _stringWriter;
+ private IndentedTextWriter _indentedTextWriter;
+
+ public override string ToString()
+ {
+ _indentedTextWriter.Flush();
+ return _stringWriter.ToString();
+ }
+
+ public BindingInterceptorCodeBuilder(int indent = 0)
+ {
+ _stringWriter = new StringWriter(CultureInfo.InvariantCulture);
+ _indentedTextWriter = new IndentedTextWriter(_stringWriter, "\t") { Indent = indent };
+ }
+
+ public void AppendSetBindingInterceptor(int id, SetBindingInvocationDescription binding)
+ {
+ AppendBlankLine();
+
+ AppendLine(GeneratedCodeAttribute);
+ AppendInterceptorAttribute(binding.Location);
+ Append($"public static void SetBinding{id}");
+ if (binding.SourceType.IsGenericParameter && binding.PropertyType.IsGenericParameter)
+ {
+ Append($"<{binding.SourceType}, {binding.PropertyType}>");
+ }
+ else if (binding.SourceType.IsGenericParameter)
+ {
+ Append($"<{binding.SourceType}>");
+ }
+ else if (binding.PropertyType.IsGenericParameter)
+ {
+ Append($"<{binding.PropertyType}>");
+ }
+ AppendLine('(');
+
+ AppendLines($$"""
+ this BindableObject bindableObject,
+ BindableProperty bindableProperty,
+ Func<{{binding.SourceType}}, {{binding.PropertyType}}> getter,
+ BindingMode mode = BindingMode.Default,
+ IValueConverter? converter = null,
+ object? converterParameter = null,
+ string? stringFormat = null,
+ object? source = null,
+ object? fallbackValue = null,
+ object? targetNullValue = null)
+ {
+ Action<{{binding.SourceType}}, {{binding.PropertyType}}>? setter = null;
+ if (ShouldUseSetter(mode, bindableProperty))
+ {
+ """);
+
+ Indent();
+ Indent();
+
+ if (binding.SetterOptions.IsWritable)
+ {
+ AppendLines("""
+ setter = static (source, value) =>
+ {
+ """);
+ Indent();
+
+ AppendSetterAction(binding);
+
+ Unindent();
+ AppendLine("};");
+ }
+ else
+ {
+ // TODO is this too strict? I believe today when the Binding can't write to the property, it just silently ignores the value
+ AppendLine("throw new InvalidOperationException(\"Cannot set value on the source object.\");"); // TODO improve exception wording
+ }
+
+ Unindent();
+ Unindent();
+
+ AppendLines($$"""
+ }
+
+ var binding = new TypedBinding<{{binding.SourceType}}, {{binding.PropertyType}}>(
+ getter: source => (getter(source), true),
+ setter,
+ """);
+
+
+ Indent();
+ Indent();
+
+ Append("handlers: ");
+ AppendHandlersArray(binding);
+ AppendLine(")");
+
+ Unindent();
+ Unindent();
+
+ AppendLines($$"""
+ {
+ Mode = mode,
+ Converter = converter,
+ ConverterParameter = converterParameter,
+ StringFormat = stringFormat,
+ Source = source,
+ FallbackValue = fallbackValue,
+ TargetNullValue = targetNullValue
+ };
+
+ bindableObject.SetBinding(bindableProperty, binding);
+ }
+ """);
+ }
+
+ private void AppendInterceptorAttribute(InterceptorLocation location)
+ {
+ AppendLine($"[InterceptsLocationAttribute(@\"{location.FilePath}\", {location.Line}, {location.Column})]");
+ }
+
+ private void AppendSetterAction(SetBindingInvocationDescription binding, string sourceVariableName = "source", string valueVariableName = "value")
+ {
+ var assignedValueExpression = valueVariableName;
+
+ // early return for nullable values if the setter doesn't accept them
+ if (binding.PropertyType.IsNullable && !binding.SetterOptions.AcceptsNullValue)
+ {
+ if (binding.PropertyType.IsValueType)
+ {
+ AppendLine($"if (!{valueVariableName}.HasValue)");
+ assignedValueExpression = $"{valueVariableName}.Value";
+ }
+ else
+ {
+ AppendLine($"if ({valueVariableName} is null)");
+ }
+ AppendLine('{');
+ Indent();
+ AppendLine("return;");
+ Unindent();
+ AppendLine('}');
+ }
+
+ var setter = Setter.From(binding.Path, sourceVariableName, assignedValueExpression);
+ if (setter.PatternMatchingExpressions.Length > 0)
+ {
+ Append("if (");
+
+ for (int i = 0; i < setter.PatternMatchingExpressions.Length; i++)
+ {
+ if (i == 1)
+ {
+ Indent();
+ }
+
+ if (i > 0)
+ {
+ AppendBlankLine();
+ Append("&& ");
+ }
+
+ Append(setter.PatternMatchingExpressions[i]);
+ }
+
+ AppendLine(')');
+ if (setter.PatternMatchingExpressions.Length > 1)
+ {
+ Unindent();
+ }
+
+ AppendLine('{');
+ Indent();
+ }
+
+ AppendLine(setter.AssignmentStatement);
+
+ if (setter.PatternMatchingExpressions.Length > 0)
+ {
+ Unindent();
+ AppendLine('}');
+ }
+ }
+
+ private void AppendHandlersArray(SetBindingInvocationDescription binding)
+ {
+ AppendLine($"new Tuple, string>[]");
+ AppendLine('{');
+
+ Indent();
+
+ string nextExpression = "source";
+ bool forceConditonalAccessToNextPart = false;
+ foreach (var part in binding.Path)
+ {
+ var previousExpression = nextExpression;
+ nextExpression = AccessExpressionBuilder.ExtendExpression(previousExpression, MaybeWrapInConditionalAccess(part, forceConditonalAccessToNextPart));
+ var isNullableReferenceType = part is MemberAccess memberAccess && !memberAccess.IsValueType;
+ forceConditonalAccessToNextPart = part is Cast;
+
+ // Some parts don't have a property name, so we can't generate a handler for them (for example casts)
+ if (part.PropertyName is string propertyName)
+ {
+ AppendLine($"new(static source => {previousExpression}, \"{propertyName}\"),");
+ }
+ }
+ Unindent();
+
+ Append('}');
+
+ static IPathPart MaybeWrapInConditionalAccess(IPathPart part, bool forceConditonalAccess)
+ {
+ if (!forceConditonalAccess)
+ {
+ return part;
+ }
+
+ return part switch
+ {
+ MemberAccess memberAccess => new ConditionalAccess(memberAccess),
+ IndexAccess indexAccess => new ConditionalAccess(indexAccess),
+ _ => part,
+ };
+ }
+ }
+
+ public void Dispose()
+ {
+ _indentedTextWriter.Dispose();
+ _stringWriter.Dispose();
+ }
+
+ private void AppendBlankLine() => _indentedTextWriter.WriteLine();
+ private void AppendLine(string line) => _indentedTextWriter.WriteLine(line);
+ private void AppendLine(char character) => _indentedTextWriter.WriteLine(character);
+ private void Append(string str) => _indentedTextWriter.Write(str);
+ private void Append(char character) => _indentedTextWriter.Write(character);
+
+ private readonly char[] LineSeparators = ['\n', '\r'];
+ private void AppendLines(string lines)
+ {
+ foreach (var line in lines.Split(LineSeparators, StringSplitOptions.RemoveEmptyEntries))
+ {
+ AppendLine(line);
+ }
+ }
+
+ private void Indent() => _indentedTextWriter.Indent++;
+ private void Unindent() => _indentedTextWriter.Indent--;
+ }
+}
diff --git a/src/Controls/src/BindingSourceGen/BindingSourceGenerator.cs b/src/Controls/src/BindingSourceGen/BindingSourceGenerator.cs
new file mode 100644
index 000000000000..e74718a0c195
--- /dev/null
+++ b/src/Controls/src/BindingSourceGen/BindingSourceGenerator.cs
@@ -0,0 +1,235 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Microsoft.Maui.Controls.BindingSourceGen;
+
+[Generator(LanguageNames.CSharp)]
+public class BindingSourceGenerator : IIncrementalGenerator
+{
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var bindingsWithDiagnostics = context.SyntaxProvider.CreateSyntaxProvider(
+ predicate: static (node, _) => IsSetBindingMethod(node),
+ transform: static (ctx, t) => GetBindingForGeneration(ctx, t)
+ )
+ .WithTrackingName(TrackingNames.BindingsWithDiagnostics);
+
+
+ context.RegisterSourceOutput(bindingsWithDiagnostics, (spc, bindingWithDiagnostic) =>
+ {
+ foreach (var diagnostic in bindingWithDiagnostic.Diagnostics)
+ {
+ spc.ReportDiagnostic(Diagnostic.Create(diagnostic.Descriptor, diagnostic.Location?.ToLocation()));
+ }
+ });
+
+ var bindings = bindingsWithDiagnostics
+ .Where(static binding => !binding.HasDiagnostics)
+ .Select(static (binding, t) => binding.Value)
+ .WithTrackingName(TrackingNames.Bindings)
+ .Collect();
+
+
+ context.RegisterSourceOutput(bindings, (spc, bindings) =>
+ {
+ var codeWriter = new BindingCodeWriter();
+
+ foreach (var binding in bindings)
+ {
+ codeWriter.AddBinding(binding);
+ }
+
+ spc.AddSource("GeneratedBindableObjectExtensions.g.cs", codeWriter.GenerateCode());
+ });
+ }
+
+ private static bool IsSetBindingMethod(SyntaxNode node)
+ {
+ return node is InvocationExpressionSyntax invocation
+ && invocation.Expression is MemberAccessExpressionSyntax method
+ && method.Name.Identifier.Text == "SetBinding"
+ && invocation.ArgumentList.Arguments.Count >= 2
+ && invocation.ArgumentList.Arguments[1].Expression is not LiteralExpressionSyntax
+ && invocation.ArgumentList.Arguments[1].Expression is not ObjectCreationExpressionSyntax;
+ }
+
+ private static Result GetBindingForGeneration(GeneratorSyntaxContext context, CancellationToken t)
+ {
+ var diagnostics = new List();
+ var enabledNullable = IsNullableContextEnabled(context);
+
+ var invocation = (InvocationExpressionSyntax)context.Node;
+ var method = (MemberAccessExpressionSyntax)invocation.Expression;
+
+ var sourceCodeLocation = SourceCodeLocation.CreateFrom(method.Name.GetLocation());
+ if (sourceCodeLocation == null)
+ {
+ return Result.Failure(DiagnosticsFactory.UnableToResolvePath(invocation.GetLocation()));
+ }
+
+ var overloadDiagnostics = VerifyCorrectOverload(invocation, context, t);
+ if (overloadDiagnostics.Length > 0)
+ {
+ return Result.Failure(overloadDiagnostics);
+ }
+
+ var lambdaResult = ExtractLambda(invocation);
+ if (lambdaResult.HasDiagnostics)
+ {
+ return Result.Failure(lambdaResult.Diagnostics);
+ }
+
+ var lambdaBodyResult = ExtractLambdaBody(lambdaResult.Value);
+ if (lambdaBodyResult.HasDiagnostics)
+ {
+ return Result.Failure(lambdaBodyResult.Diagnostics);
+ }
+
+ var lambdaSymbolResult = GetLambdaSymbol(lambdaResult.Value, context.SemanticModel);
+ if (lambdaSymbolResult.HasDiagnostics)
+ {
+ return Result.Failure(lambdaSymbolResult.Diagnostics);
+ }
+
+ var lambdaTypeInfo = context.SemanticModel.GetTypeInfo(lambdaBodyResult.Value, t);
+ if (lambdaTypeInfo.Type == null)
+ {
+ return Result.Failure(DiagnosticsFactory.UnableToResolvePath(lambdaBodyResult.Value.GetLocation()));
+ }
+
+ var pathParser = new PathParser(context, enabledNullable);
+ var pathParseResult = pathParser.ParsePath(lambdaBodyResult.Value);
+ if (pathParseResult.HasDiagnostics)
+ {
+ return Result.Failure(pathParseResult.Diagnostics);
+ }
+
+ var binding = new SetBindingInvocationDescription(
+ Location: sourceCodeLocation.ToInterceptorLocation(),
+ SourceType: BindingGenerationUtilities.CreateTypeDescription(lambdaSymbolResult.Value.Parameters[0].Type, enabledNullable),
+ PropertyType: BindingGenerationUtilities.CreateTypeDescription(lambdaTypeInfo.Type, enabledNullable),
+ Path: new EquatableArray([.. pathParseResult.Value]),
+ SetterOptions: DeriveSetterOptions(lambdaBodyResult.Value, context.SemanticModel, enabledNullable),
+ NullableContextEnabled: enabledNullable);
+ return Result.Success(binding);
+ }
+
+ private static bool IsNullableContextEnabled(GeneratorSyntaxContext context)
+ {
+ NullableContext nullableContext = context.SemanticModel.GetNullableContext(context.Node.Span.Start);
+ return (nullableContext & NullableContext.Enabled) == NullableContext.Enabled;
+ }
+
+ private static EquatableArray VerifyCorrectOverload(InvocationExpressionSyntax invocation, GeneratorSyntaxContext context, CancellationToken t)
+ {
+ var argumentList = invocation.ArgumentList.Arguments;
+
+ if (argumentList.Count < 2)
+ {
+ throw new ArgumentOutOfRangeException(nameof(invocation));
+ }
+
+ var secondArgument = argumentList[1].Expression;
+
+ if (secondArgument is IdentifierNameSyntax)
+ {
+ var type = context.SemanticModel.GetTypeInfo(secondArgument, cancellationToken: t).Type;
+ if (type != null && type.Name == "Func")
+ {
+ return new EquatableArray([DiagnosticsFactory.GetterIsNotLambda(secondArgument.GetLocation())]);
+ }
+ else // String and Binding
+ {
+ return new EquatableArray([DiagnosticsFactory.SuboptimalSetBindingOverload(secondArgument.GetLocation())]);
+ }
+ }
+
+ return [];
+ }
+
+ private static Result ExtractLambda(InvocationExpressionSyntax invocation)
+ {
+ var argumentList = invocation.ArgumentList.Arguments;
+ var lambda = argumentList[1].Expression;
+
+ if (lambda is not LambdaExpressionSyntax lambdaExpression)
+ {
+ return Result.Failure(DiagnosticsFactory.GetterIsNotLambda(lambda.GetLocation()));
+ }
+ return Result.Success(lambdaExpression);
+
+ }
+
+ private static Result ExtractLambdaBody(LambdaExpressionSyntax lambdaExpression)
+ {
+ if (lambdaExpression.Body is not ExpressionSyntax lambdaBody)
+ {
+ return Result.Failure(DiagnosticsFactory.GetterLambdaBodyIsNotExpression(lambdaExpression.Body.GetLocation()));
+
+ }
+ return Result.Success(lambdaBody);
+ }
+
+ private static Result GetLambdaSymbol(LambdaExpressionSyntax lambda, SemanticModel semanticModel)
+ {
+ if (semanticModel.GetSymbolInfo(lambda).Symbol is not IMethodSymbol lambdaSymbol)
+ {
+ return Result.Failure(DiagnosticsFactory.GetterIsNotLambda(lambda.GetLocation()));
+ }
+
+ return Result.Success(lambdaSymbol);
+ }
+
+ private static SetterOptions DeriveSetterOptions(ExpressionSyntax? lambdaBodyExpression, SemanticModel semanticModel, bool enabledNullable)
+ {
+ if (lambdaBodyExpression is null)
+ {
+ return new SetterOptions(IsWritable: false, AcceptsNullValue: false);
+ }
+ else if (lambdaBodyExpression is IdentifierNameSyntax identifier)
+ {
+ var symbol = semanticModel.GetSymbolInfo(identifier).Symbol;
+ return new SetterOptions(IsWritable(symbol), AcceptsNullValue(symbol, enabledNullable));
+ }
+ else if (lambdaBodyExpression is ElementAccessExpressionSyntax elementAccess)
+ {
+ var symbol = semanticModel.GetSymbolInfo(elementAccess).Symbol;
+ return new SetterOptions(IsWritable(symbol), AcceptsNullValue(symbol, enabledNullable));
+ }
+ else if (lambdaBodyExpression is ElementBindingExpressionSyntax elementBinding)
+ {
+ var symbol = semanticModel.GetSymbolInfo(elementBinding).Symbol;
+ return new SetterOptions(IsWritable(symbol), AcceptsNullValue(symbol, enabledNullable));
+ }
+
+ var nestedExpression = lambdaBodyExpression switch
+ {
+ MemberAccessExpressionSyntax memberAccess => memberAccess.Name,
+ ConditionalAccessExpressionSyntax conditionalAccess => conditionalAccess.WhenNotNull,
+ MemberBindingExpressionSyntax memberBinding => memberBinding.Name,
+ BinaryExpressionSyntax binary when binary.Kind() == SyntaxKind.AsExpression => binary.Left,
+ CastExpressionSyntax cast => cast.Expression,
+ ParenthesizedExpressionSyntax parenthesized => parenthesized.Expression,
+ _ => null,
+ };
+
+ return DeriveSetterOptions(nestedExpression, semanticModel, enabledNullable);
+
+ static bool IsWritable(ISymbol? symbol)
+ => symbol switch
+ {
+ IPropertySymbol propertySymbol => propertySymbol.SetMethod != null,
+ IFieldSymbol fieldSymbol => !fieldSymbol.IsReadOnly,
+ _ => true,
+ };
+
+ static bool AcceptsNullValue(ISymbol? symbol, bool enabledNullable)
+ => symbol switch
+ {
+ IPropertySymbol propertySymbol => BindingGenerationUtilities.IsTypeNullable(propertySymbol.Type, enabledNullable),
+ IFieldSymbol fieldSymbol => BindingGenerationUtilities.IsTypeNullable(fieldSymbol.Type, enabledNullable),
+ _ => false,
+ };
+ }
+}
diff --git a/src/Controls/src/BindingSourceGen/BindingSourceGeneratorUtilities.cs b/src/Controls/src/BindingSourceGen/BindingSourceGeneratorUtilities.cs
new file mode 100644
index 000000000000..9ddcbf865fa3
--- /dev/null
+++ b/src/Controls/src/BindingSourceGen/BindingSourceGeneratorUtilities.cs
@@ -0,0 +1,45 @@
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.Maui.Controls.BindingSourceGen;
+
+internal static class BindingGenerationUtilities
+{
+ internal static bool IsTypeNullable(ITypeSymbol typeInfo, bool enabledNullable)
+ {
+ if (!enabledNullable && typeInfo.IsReferenceType)
+ {
+ return true;
+ }
+
+ return IsNullableValueType(typeInfo) || IsNullableReferenceType(typeInfo);
+ }
+
+ private static bool IsNullableValueType(ITypeSymbol typeInfo) =>
+ typeInfo is INamedTypeSymbol namedTypeSymbol
+ && namedTypeSymbol.IsGenericType
+ && namedTypeSymbol.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T;
+
+ private static bool IsNullableReferenceType(ITypeSymbol typeInfo) =>
+ typeInfo.IsReferenceType && typeInfo.NullableAnnotation == NullableAnnotation.Annotated;
+
+ internal static TypeDescription CreateTypeDescription(ITypeSymbol typeSymbol, bool enabledNullable)
+ {
+ var isNullable = IsTypeNullable(typeSymbol, enabledNullable);
+ return new TypeDescription(
+ GlobalName: GetGlobalName(typeSymbol, isNullable, typeSymbol.IsValueType),
+ IsNullable: isNullable,
+ IsGenericParameter: typeSymbol.Kind == SymbolKind.TypeParameter, //TODO: Add support for generic parameters
+ IsValueType: typeSymbol.IsValueType);
+ }
+
+ internal static string GetGlobalName(ITypeSymbol typeSymbol, bool isNullable, bool isValueType)
+ {
+ if (isNullable && isValueType)
+ {
+ // Strips the "?" from the type name
+ return ((INamedTypeSymbol)typeSymbol).TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ }
+
+ return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ }
+}
diff --git a/src/Controls/src/BindingSourceGen/BindingTransformer.cs b/src/Controls/src/BindingSourceGen/BindingTransformer.cs
new file mode 100644
index 000000000000..4056b52e068f
--- /dev/null
+++ b/src/Controls/src/BindingSourceGen/BindingTransformer.cs
@@ -0,0 +1,50 @@
+
+namespace Microsoft.Maui.Controls.BindingSourceGen;
+
+public interface IBindingInvocationTransformer
+{
+ SetBindingInvocationDescription Transform(SetBindingInvocationDescription setBindingInvocationDescription);
+}
+
+public class ReferenceTypesConditionalAccessTransformer : IBindingInvocationTransformer
+{
+ public SetBindingInvocationDescription Transform(SetBindingInvocationDescription setBindingInvocationDescription)
+ {
+ var path = TransformPath(setBindingInvocationDescription);
+ return setBindingInvocationDescription with { Path = path };
+ }
+
+ private static EquatableArray TransformPath(SetBindingInvocationDescription setBindingInvocationDescription)
+ {
+ var newPath = new List();
+ foreach (var pathPart in setBindingInvocationDescription.Path)
+ {
+ var sourceIsReferenceType = newPath.Count == 0 && !setBindingInvocationDescription.SourceType.IsValueType;
+ var previousPartIsReferenceType = newPath.Count > 0 && PreviousPartIsReferenceType(newPath.Last());
+
+ if (pathPart is not MemberAccess && pathPart is not IndexAccess)
+ {
+ newPath.Add(pathPart);
+ }
+ else if (sourceIsReferenceType || previousPartIsReferenceType)
+ {
+ newPath.Add(new ConditionalAccess(pathPart));
+ }
+ else
+ {
+ newPath.Add(pathPart);
+ }
+ }
+
+ return new EquatableArray(newPath.ToArray());
+
+ static bool PreviousPartIsReferenceType(IPathPart previousPathPart) =>
+ previousPathPart switch
+ {
+ MemberAccess memberAccess => !memberAccess.IsValueType,
+ IndexAccess indexAccess => !indexAccess.IsValueType,
+ ConditionalAccess { Part: var inner } => PreviousPartIsReferenceType(inner),
+ _ => false,
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/Controls/src/BindingSourceGen/Controls.BindingSourceGen.csproj b/src/Controls/src/BindingSourceGen/Controls.BindingSourceGen.csproj
new file mode 100644
index 000000000000..d7df9f008760
--- /dev/null
+++ b/src/Controls/src/BindingSourceGen/Controls.BindingSourceGen.csproj
@@ -0,0 +1,32 @@
+
+
+ netstandard2.0
+ enable
+ true
+ Latest
+ Microsoft.Maui.Controls.BindingSourceGen
+ Microsoft.Maui.Controls.BindingSourceGen
+ Microsoft.Maui.Controls.BindingSourceGen
+ false
+
+
+
+ false
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
+ <_CopyItems Include="$(TargetDir)*.dll" Exclude="$(TargetDir)System.*.dll" />
+ <_CopyItems Include="$(TargetDir)*.pdb" Exclude="$(TargetDir)System.*.pdb" />
+
+
+
+
\ No newline at end of file
diff --git a/src/Controls/src/BindingSourceGen/DiagnosticsFactory.cs b/src/Controls/src/BindingSourceGen/DiagnosticsFactory.cs
new file mode 100644
index 000000000000..d219c7efd255
--- /dev/null
+++ b/src/Controls/src/BindingSourceGen/DiagnosticsFactory.cs
@@ -0,0 +1,62 @@
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.Maui.Controls.BindingSourceGen;
+
+public sealed record DiagnosticInfo
+{
+ public DiagnosticInfo(DiagnosticDescriptor descriptor, Location? location)
+ {
+ Descriptor = descriptor;
+ Location = location is not null ? SourceCodeLocation.CreateFrom(location) : null;
+ }
+
+ public DiagnosticDescriptor Descriptor { get; }
+ public SourceCodeLocation? Location { get; }
+}
+
+internal static class DiagnosticsFactory
+{
+ public static DiagnosticInfo UnableToResolvePath(Location location)
+ => new(
+ new DiagnosticDescriptor(
+ id: "BSG0001",
+ title: "Invalid getter method",
+ messageFormat: "The getter expression is not valid. The expression can only consist of property access, index access, and type casts.",
+ category: "Usage",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true),
+ location);
+
+ public static DiagnosticInfo GetterIsNotLambda(Location location)
+ => new(
+ new DiagnosticDescriptor(
+ id: "BSG0002",
+ title: "Getter method is not a lambda",
+ messageFormat: "The getter must be a lambda expression.",
+ category: "Usage",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true),
+ location);
+
+ public static DiagnosticInfo GetterLambdaBodyIsNotExpression(Location location)
+ => new(
+ new DiagnosticDescriptor(
+ id: "BSG0003",
+ title: "Getter method body is not an expression",
+ messageFormat: "The getter lambda's body must be an expression.",
+ category: "Usage",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true),
+ location);
+
+ public static DiagnosticInfo SuboptimalSetBindingOverload(Location location)
+ => new(
+ new DiagnosticDescriptor(
+ id: "BSG0004",
+ title: "Using SetBinding with a string path",
+ messageFormat: "Consider using SetBinding with a lambda expression for improved performance.",
+ category: "Usage",
+ defaultSeverity: DiagnosticSeverity.Hidden,
+ isEnabledByDefault: false),
+ location);
+}
diff --git a/src/Controls/src/BindingSourceGen/EquatableArray.cs b/src/Controls/src/BindingSourceGen/EquatableArray.cs
new file mode 100644
index 000000000000..3cbad3b3e2b7
--- /dev/null
+++ b/src/Controls/src/BindingSourceGen/EquatableArray.cs
@@ -0,0 +1,102 @@
+using System.Collections;
+using System.Collections.Immutable;
+using System.Runtime.CompilerServices;
+
+namespace Microsoft.Maui.Controls.BindingSourceGen;
+
+// Original source:
+// https://github.com/CommunityToolkit/dotnet/blob/main/src/CommunityToolkit.Mvvm.SourceGenerators/Helpers/EquatableArray%7BT%7D.cs
+
+public readonly struct EquatableArray : IEquatable>, IEnumerable
+ where T : IEquatable
+{
+ private readonly T[]? array;
+
+ private EquatableArray(ImmutableArray array)
+ {
+ this.array = Unsafe.As, T[]?>(ref array);
+ }
+
+ public EquatableArray(T[] array) : this(array.ToImmutableArray())
+ {
+ }
+
+ public ref readonly T this[int index]
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ get => ref AsImmutableArray().ItemRef(index);
+ }
+
+ public int Length
+ {
+ get => array?.Length ?? 0;
+ }
+
+ public bool Equals(EquatableArray array)
+ {
+ return AsSpan().SequenceEqual(array.AsSpan());
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is EquatableArray array && Equals(this, array);
+ }
+
+ public override int GetHashCode()
+ {
+ if (this.array is not T[] array)
+ {
+ return 0;
+ }
+
+ HashCode hashCode = default;
+
+ foreach (T item in array)
+ {
+ hashCode.Add(item);
+ }
+
+ return hashCode.ToHashCode();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public ImmutableArray AsImmutableArray()
+ {
+ return Unsafe.As>(ref Unsafe.AsRef(in this.array));
+ }
+
+ public ReadOnlySpan AsSpan()
+ {
+ return AsImmutableArray().AsSpan();
+ }
+
+ public T[] ToArray()
+ {
+ return AsImmutableArray().ToArray();
+ }
+
+ public ImmutableArray.Enumerator GetEnumerator()
+ {
+ return AsImmutableArray().GetEnumerator();
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return ((IEnumerable)AsImmutableArray()).GetEnumerator();
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return ((IEnumerable)AsImmutableArray()).GetEnumerator();
+ }
+
+ public static bool operator ==(EquatableArray left, EquatableArray right)
+ {
+ return left.Equals(right);
+ }
+
+ public static bool operator !=(EquatableArray left, EquatableArray right)
+ {
+ return !left.Equals(right);
+ }
+}
diff --git a/src/Controls/src/BindingSourceGen/GeneratorDataModels.cs b/src/Controls/src/BindingSourceGen/GeneratorDataModels.cs
new file mode 100644
index 000000000000..81cb6545f473
--- /dev/null
+++ b/src/Controls/src/BindingSourceGen/GeneratorDataModels.cs
@@ -0,0 +1,123 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+namespace Microsoft.Maui.Controls.BindingSourceGen;
+
+public class TrackingNames
+{
+ public const string BindingsWithDiagnostics = nameof(BindingsWithDiagnostics);
+ public const string Bindings = nameof(Bindings);
+}
+public sealed record SetBindingInvocationDescription(
+ InterceptorLocation Location,
+ TypeDescription SourceType,
+ TypeDescription PropertyType,
+ EquatableArray Path,
+ SetterOptions SetterOptions,
+ bool NullableContextEnabled);
+
+public sealed record SourceCodeLocation(string FilePath, TextSpan TextSpan, LinePositionSpan LineSpan)
+{
+ public static SourceCodeLocation? CreateFrom(Location location)
+ => location.SourceTree is null
+ ? null
+ : new SourceCodeLocation(location.SourceTree.FilePath, location.SourceSpan, location.GetLineSpan().Span);
+
+ public Location ToLocation()
+ {
+ return Location.Create(FilePath, TextSpan, LineSpan);
+ }
+
+ public InterceptorLocation ToInterceptorLocation()
+ {
+ return new InterceptorLocation(FilePath, LineSpan.Start.Line + 1, LineSpan.Start.Character + 1);
+ }
+}
+
+public sealed record InterceptorLocation(string FilePath, int Line, int Column);
+
+public sealed record TypeDescription(
+ string GlobalName,
+ bool IsValueType = false,
+ bool IsNullable = false,
+ bool IsGenericParameter = false)
+{
+ public override string ToString()
+ => IsNullable
+ ? $"{GlobalName}?"
+ : GlobalName;
+}
+
+public sealed record SetterOptions(bool IsWritable, bool AcceptsNullValue = false);
+
+public sealed record MemberAccess(string MemberName, bool IsValueType = false) : IPathPart
+{
+ public string PropertyName => MemberName;
+
+ public bool Equals(IPathPart other)
+ {
+ return other is MemberAccess memberAccess
+ && MemberName == memberAccess.MemberName
+ && IsValueType == memberAccess.IsValueType;
+ }
+}
+
+public sealed record IndexAccess(string DefaultMemberName, object Index, bool IsValueType = false) : IPathPart
+{
+ public string? PropertyName => $"{DefaultMemberName}[{Index}]";
+
+ public bool Equals(IPathPart other)
+ {
+ return other is IndexAccess indexAccess
+ && DefaultMemberName == indexAccess.DefaultMemberName
+ && Index.Equals(indexAccess.Index)
+ && IsValueType == indexAccess.IsValueType;
+ }
+}
+
+public sealed record ConditionalAccess(IPathPart Part) : IPathPart
+{
+ public string? PropertyName => Part.PropertyName;
+
+ public bool Equals(IPathPart other)
+ {
+ return other is ConditionalAccess conditionalAccess && Part.Equals(conditionalAccess.Part);
+ }
+}
+
+public sealed record Cast(TypeDescription TargetType) : IPathPart
+{
+ public string? PropertyName => null;
+
+ public bool Equals(IPathPart other)
+ {
+ return other is Cast cast && TargetType.Equals(cast.TargetType);
+ }
+}
+
+public interface IPathPart : IEquatable
+{
+ public string? PropertyName { get; }
+}
+
+internal sealed record Result(T? OptionalValue, EquatableArray Diagnostics)
+{
+ public bool HasDiagnostics => Diagnostics.Length > 0;
+
+ public T Value => OptionalValue ?? throw new InvalidOperationException("Result does not contain a value.");
+
+ public static Result Success(T value)
+ {
+ return new Result(value, new EquatableArray(Array.Empty()));
+ }
+
+ public static Result Failure(EquatableArray diagnostics)
+ {
+ return new Result(default, diagnostics);
+ }
+
+ public static Result Failure(DiagnosticInfo diagnostic)
+ {
+ return new Result(default, new EquatableArray(new[] { diagnostic }));
+ }
+}
diff --git a/src/Controls/src/BindingSourceGen/HashCode.cs b/src/Controls/src/BindingSourceGen/HashCode.cs
new file mode 100644
index 000000000000..89ad0ec72460
--- /dev/null
+++ b/src/Controls/src/BindingSourceGen/HashCode.cs
@@ -0,0 +1,157 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+// Original source:
+// https://github.com/dotnet/BenchmarkDotNet/blob/master/src/BenchmarkDotNet/Helpers/HashCode.cs
+
+// Mimics System.HashCode, which is missing in NetStandard2.0.
+// Placed in root namespace to avoid ambiguous reference with System.HashCode
+
+namespace Microsoft.Maui.Controls.BindingSourceGen;
+
+internal struct HashCode
+{
+ private int hashCode;
+
+ public void Add(T value)
+ {
+ hashCode = Hash(hashCode, value);
+ }
+
+ public void Add(T value, IEqualityComparer comparer)
+ {
+ hashCode = Hash(hashCode, value, comparer);
+ }
+
+ public readonly int ToHashCode() => hashCode;
+
+ public static int Combine(T1 value1)
+ {
+ int hashCode = 0;
+ hashCode = Hash(hashCode, value1);
+ return hashCode;
+ }
+
+ public static int Combine(T1 value1, T2 value2)
+ {
+ int hashCode = 0;
+ hashCode = Hash(hashCode, value1);
+ hashCode = Hash(hashCode, value2);
+ return hashCode;
+ }
+
+ public static int Combine(T1 value1, T2 value2, T3 value3)
+ {
+ int hashCode = 0;
+ hashCode = Hash(hashCode, value1);
+ hashCode = Hash(hashCode, value2);
+ hashCode = Hash(hashCode, value3);
+ return hashCode;
+ }
+
+ public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4)
+ {
+ int hashCode = 0;
+ hashCode = Hash(hashCode, value1);
+ hashCode = Hash(hashCode, value2);
+ hashCode = Hash(hashCode, value3);
+ hashCode = Hash(hashCode, value4);
+ return hashCode;
+ }
+
+ public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5)
+ {
+ int hashCode = 0;
+ hashCode = Hash(hashCode, value1);
+ hashCode = Hash(hashCode, value2);
+ hashCode = Hash(hashCode, value3);
+ hashCode = Hash(hashCode, value4);
+ hashCode = Hash(hashCode, value5);
+ return hashCode;
+ }
+
+ public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5, T6 value6)
+ {
+ int hashCode = 0;
+ hashCode = Hash(hashCode, value1);
+ hashCode = Hash(hashCode, value2);
+ hashCode = Hash(hashCode, value3);
+ hashCode = Hash(hashCode, value4);
+ hashCode = Hash(hashCode, value5);
+ hashCode = Hash(hashCode, value6);
+ return hashCode;
+ }
+
+ public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5, T6 value6, T7 value7)
+ {
+ int hashCode = 0;
+ hashCode = Hash(hashCode, value1);
+ hashCode = Hash(hashCode, value2);
+ hashCode = Hash(hashCode, value3);
+ hashCode = Hash(hashCode, value4);
+ hashCode = Hash(hashCode, value5);
+ hashCode = Hash(hashCode, value6);
+ hashCode = Hash(hashCode, value7);
+ return hashCode;
+ }
+
+ public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5, T6 value6, T7 value7, T8 value8)
+ {
+ int hashCode = 0;
+ hashCode = Hash(hashCode, value1);
+ hashCode = Hash(hashCode, value2);
+ hashCode = Hash(hashCode, value3);
+ hashCode = Hash(hashCode, value4);
+ hashCode = Hash(hashCode, value5);
+ hashCode = Hash(hashCode, value6);
+ hashCode = Hash(hashCode, value7);
+ hashCode = Hash(hashCode, value8);
+ return hashCode;
+ }
+
+ public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5, T6 value6, T7 value7,
+ T8 value8, T9 value9, T10 value10)
+ {
+ int hashCode = 0;
+ hashCode = Hash(hashCode, value1);
+ hashCode = Hash(hashCode, value2);
+ hashCode = Hash(hashCode, value3);
+ hashCode = Hash(hashCode, value4);
+ hashCode = Hash(hashCode, value5);
+ hashCode = Hash(hashCode, value6);
+ hashCode = Hash(hashCode, value7);
+ hashCode = Hash(hashCode, value8);
+ hashCode = Hash(hashCode, value9);
+ hashCode = Hash(hashCode, value10);
+ return hashCode;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static int Hash(int hashCode, T value)
+ {
+ unchecked
+ {
+ return (hashCode * 397) ^ (value?.GetHashCode() ?? 0);
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static int Hash(int hashCode, T value, IEqualityComparer comparer)
+ {
+ unchecked
+ {
+ return (hashCode * 397) ^ (value is null ? 0 : (comparer?.GetHashCode(value) ?? value.GetHashCode()));
+ }
+ }
+
+#pragma warning disable CS0809 // Obsolete member 'HashCode.GetHashCode()' overrides non-obsolete member 'object.GetHashCode()'
+ [Obsolete("HashCode is a mutable struct and should not be compared with other HashCodes. Use ToHashCode to retrieve the computed hash code.",
+ error: true)]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public override int GetHashCode() => throw new NotSupportedException();
+
+ [Obsolete("HashCode is a mutable struct and should not be compared with other HashCodes.", error: true)]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public override bool Equals(object obj) => throw new NotSupportedException();
+#pragma warning restore CS0809 // Obsolete member 'HashCode.GetHashCode()' overrides non-obsolete member 'object.GetHashCode()'
+}
diff --git a/src/Controls/src/BindingSourceGen/IsExternalInitCompat.cs b/src/Controls/src/BindingSourceGen/IsExternalInitCompat.cs
new file mode 100644
index 000000000000..97b7ffc4ecae
--- /dev/null
+++ b/src/Controls/src/BindingSourceGen/IsExternalInitCompat.cs
@@ -0,0 +1,11 @@
+using System.ComponentModel;
+
+namespace System.Runtime.CompilerServices;
+
+///
+/// This dummy class is required to compile records when targeting .NET Standard
+///
+[EditorBrowsable(EditorBrowsableState.Never)]
+public static class IsExternalInit
+{
+}
diff --git a/src/Controls/src/BindingSourceGen/PathParser.cs b/src/Controls/src/BindingSourceGen/PathParser.cs
new file mode 100644
index 000000000000..2cc4e83c6092
--- /dev/null
+++ b/src/Controls/src/BindingSourceGen/PathParser.cs
@@ -0,0 +1,223 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Microsoft.Maui.Controls.BindingSourceGen;
+
+internal class PathParser
+{
+ internal PathParser(GeneratorSyntaxContext context, bool enabledNullable)
+ {
+ Context = context;
+ EnabledNullable = enabledNullable;
+ }
+
+ private GeneratorSyntaxContext Context { get; }
+ private bool EnabledNullable { get; }
+
+ internal Result> ParsePath(CSharpSyntaxNode? expressionSyntax)
+ {
+ return expressionSyntax switch
+ {
+ IdentifierNameSyntax _ => Result>.Success(new List()),
+ MemberAccessExpressionSyntax memberAccess => HandleMemberAccessExpression(memberAccess),
+ ElementAccessExpressionSyntax elementAccess => HandleElementAccessExpression(elementAccess),
+ ElementBindingExpressionSyntax elementBinding => HandleElementBindingExpression(elementBinding),
+ ConditionalAccessExpressionSyntax conditionalAccess => HandleConditionalAccessExpression(conditionalAccess),
+ MemberBindingExpressionSyntax memberBinding => HandleMemberBindingExpression(memberBinding),
+ ParenthesizedExpressionSyntax parenthesized => ParsePath(parenthesized.Expression),
+ BinaryExpressionSyntax asExpression when asExpression.Kind() == SyntaxKind.AsExpression => HandleBinaryExpression(asExpression),
+ CastExpressionSyntax castExpression => HandleCastExpression(castExpression),
+ _ => HandleDefaultCase(),
+ };
+ }
+
+ private Result> HandleMemberAccessExpression(MemberAccessExpressionSyntax memberAccess)
+ {
+ var result = ParsePath(memberAccess.Expression);
+ if (result.HasDiagnostics)
+ {
+ return result;
+ }
+
+ var member = memberAccess.Name.Identifier.Text;
+ var typeInfo = Context.SemanticModel.GetTypeInfo(memberAccess).Type;
+ var isReferenceType = typeInfo?.IsReferenceType ?? false;
+ IPathPart part = new MemberAccess(member, !isReferenceType);
+ result.Value.Add(part);
+
+ return Result>.Success(result.Value);
+ }
+
+ private Result> HandleElementAccessExpression(ElementAccessExpressionSyntax elementAccess)
+ {
+ var result = ParsePath(elementAccess.Expression);
+ if (result.HasDiagnostics)
+ {
+ return result;
+ }
+
+ var elementAccessSymbol = Context.SemanticModel.GetSymbolInfo(elementAccess).Symbol;
+ var elementType = Context.SemanticModel.GetTypeInfo(elementAccess).Type;
+
+ var elementAccessResult = CreateIndexAccess(elementAccessSymbol, elementType, elementAccess.ArgumentList.Arguments, elementAccess.GetLocation());
+ if (elementAccessResult.HasDiagnostics)
+ {
+ return elementAccessResult;
+ }
+ result.Value.AddRange(elementAccessResult.Value);
+
+ return Result>.Success(result.Value);
+ }
+
+ private Result> HandleConditionalAccessExpression(ConditionalAccessExpressionSyntax conditionalAccess)
+ {
+ var expressionResult = ParsePath(conditionalAccess.Expression);
+ if (expressionResult.HasDiagnostics)
+ {
+ return expressionResult;
+ }
+
+ var whenNotNullResult = ParsePath(conditionalAccess.WhenNotNull);
+ if (whenNotNullResult.HasDiagnostics)
+ {
+ return whenNotNullResult;
+ }
+
+ expressionResult.Value.AddRange(whenNotNullResult.Value);
+
+ return Result>.Success(expressionResult.Value);
+ }
+
+ private Result> HandleMemberBindingExpression(MemberBindingExpressionSyntax memberBinding)
+ {
+ var member = memberBinding.Name.Identifier.Text;
+ var typeInfo = Context.SemanticModel.GetTypeInfo(memberBinding).Type;
+ var isReferenceType = typeInfo?.IsReferenceType ?? false;
+ IPathPart part = new MemberAccess(member, !isReferenceType);
+ part = new ConditionalAccess(part);
+
+ return Result>.Success(new List([part]));
+ }
+
+ private Result> HandleElementBindingExpression(ElementBindingExpressionSyntax elementBinding)
+ {
+ var elementAccessSymbol = Context.SemanticModel.GetSymbolInfo(elementBinding).Symbol;
+ var elementType = Context.SemanticModel.GetTypeInfo(elementBinding).Type;
+
+ var elementAccessResult = CreateIndexAccess(elementAccessSymbol, elementType, elementBinding.ArgumentList.Arguments, elementBinding.GetLocation());
+ if (elementAccessResult.HasDiagnostics)
+ {
+ return elementAccessResult;
+ }
+
+ elementAccessResult.Value[0] = new ConditionalAccess(elementAccessResult.Value[0]);
+
+ return Result>.Success(elementAccessResult.Value);
+ }
+
+ private Result> HandleBinaryExpression(BinaryExpressionSyntax asExpression)
+ {
+ var leftResult = ParsePath(asExpression.Left);
+ if (leftResult.HasDiagnostics)
+ {
+ return leftResult;
+ }
+
+ var castTo = asExpression.Right;
+ var typeInfo = Context.SemanticModel.GetTypeInfo(castTo).Type;
+ if (typeInfo == null)
+ {
+ return Result>.Failure(DiagnosticsFactory.UnableToResolvePath(castTo.GetLocation()));
+ };
+
+ leftResult.Value.Add(new Cast(BindingGenerationUtilities.CreateTypeDescription(typeInfo, EnabledNullable)));
+
+ return Result>.Success(leftResult.Value);
+ }
+
+ private Result> HandleCastExpression(CastExpressionSyntax castExpression)
+ {
+ var result = ParsePath(castExpression.Expression);
+ if (result.HasDiagnostics)
+ {
+ return result;
+ }
+
+ var typeInfo = Context.SemanticModel.GetTypeInfo(castExpression.Type).Type;
+ if (typeInfo == null)
+ {
+ return Result>.Failure(DiagnosticsFactory.UnableToResolvePath(castExpression.GetLocation()));
+ };
+
+ result.Value.Add(new Cast(BindingGenerationUtilities.CreateTypeDescription(typeInfo, EnabledNullable)));
+
+ return Result>.Success(result.Value);
+ }
+
+ private Result> HandleDefaultCase()
+ {
+ return Result>.Failure(DiagnosticsFactory.UnableToResolvePath(Context.Node.GetLocation()));
+ }
+
+ private Result> CreateIndexAccess(ISymbol? elementAccessSymbol, ITypeSymbol? typeSymbol, SeparatedSyntaxList argumentList, Location location)
+ {
+ if (argumentList.Count != 1)
+ {
+ return Result>.Failure(DiagnosticsFactory.UnableToResolvePath(location));
+ }
+
+ var indexExpression = argumentList[0].Expression;
+ object? indexValue = Context.SemanticModel.GetConstantValue(indexExpression).Value;
+ if (indexValue is null)
+ {
+ return Result>.Failure(DiagnosticsFactory.UnableToResolvePath(indexExpression.GetLocation()));
+ }
+
+ var name = GetIndexerName(elementAccessSymbol);
+ var isReferenceType = typeSymbol?.IsReferenceType ?? false;
+ IPathPart part = new IndexAccess(name, indexValue, !isReferenceType);
+
+ return Result>.Success(new List([part]));
+ }
+
+ private string GetIndexerName(ISymbol? elementAccessSymbol)
+ {
+ const string defaultName = "Item";
+
+ if (elementAccessSymbol is not IPropertySymbol propertySymbol)
+ {
+ return defaultName;
+ }
+
+ var containgType = propertySymbol.ContainingType;
+ if (containgType == null)
+ {
+ return defaultName;
+ }
+
+ var defaultMemberAttribute = GetAttribute(containgType, "DefaultMemberAttribute");
+ if (defaultMemberAttribute != null)
+ {
+ return GetAttributeValue(defaultMemberAttribute);
+ }
+
+ var indexerNameAttr = GetAttribute(propertySymbol, "IndexerNameAttribute");
+ if (indexerNameAttr != null)
+ {
+ return GetAttributeValue(indexerNameAttr);
+ }
+
+ return defaultName;
+
+ AttributeData? GetAttribute(ISymbol symbol, string attributeName)
+ {
+ return symbol.GetAttributes().FirstOrDefault(attr => attr.AttributeClass?.Name == attributeName);
+ }
+
+ string GetAttributeValue(AttributeData attribute)
+ {
+ return (attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value as string : null) ?? defaultName;
+ }
+ }
+}
diff --git a/src/Controls/src/BindingSourceGen/Setter.cs b/src/Controls/src/BindingSourceGen/Setter.cs
new file mode 100644
index 000000000000..2545bb5e4d85
--- /dev/null
+++ b/src/Controls/src/BindingSourceGen/Setter.cs
@@ -0,0 +1,53 @@
+namespace Microsoft.Maui.Controls.BindingSourceGen;
+
+public sealed record Setter(string[] PatternMatchingExpressions, string AssignmentStatement)
+{
+ public static Setter From(
+ IEnumerable path,
+ string sourceVariableName = "source",
+ string assignedValueExpression = "value")
+ {
+ string accessAccumulator = sourceVariableName;
+ List patternMatchingExpressions = new();
+ bool skipNextConditionalAccess = false;
+
+ void AddPatternMatchingExpression(string pattern)
+ {
+ var tmpVariableName = $"p{patternMatchingExpressions.Count}";
+ patternMatchingExpressions.Add($"{accessAccumulator} is {pattern} {tmpVariableName}");
+ accessAccumulator = tmpVariableName;
+ }
+
+ foreach (var part in path)
+ {
+ var skipConditionalAccess = skipNextConditionalAccess;
+ skipNextConditionalAccess = false;
+
+ if (part is Cast { TargetType: var targetType })
+ {
+ AddPatternMatchingExpression(targetType.GlobalName);
+
+ // the current `is T` expression makes sure that the value is not null
+ // so if a conditional access to a member/indexer follows, we can skip the next null check
+ skipNextConditionalAccess = true;
+ }
+ else if (part is ConditionalAccess { Part: var innerPart })
+ {
+ if (!skipConditionalAccess)
+ {
+ AddPatternMatchingExpression("{}");
+ }
+
+ accessAccumulator = AccessExpressionBuilder.ExtendExpression(accessAccumulator, innerPart);
+ }
+ else
+ {
+ accessAccumulator = AccessExpressionBuilder.ExtendExpression(accessAccumulator, part);
+ }
+ }
+
+ return new Setter(
+ patternMatchingExpressions.ToArray(),
+ AssignmentStatement: $"{accessAccumulator} = {assignedValueExpression};");
+ }
+}
diff --git a/src/Controls/src/Core/BindableObjectExtensions.cs b/src/Controls/src/Core/BindableObjectExtensions.cs
index e0545128f0b8..c956579f0f02 100644
--- a/src/Controls/src/Core/BindableObjectExtensions.cs
+++ b/src/Controls/src/Core/BindableObjectExtensions.cs
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.ComponentModel;
using System.Linq;
using Microsoft.Maui.Graphics;
@@ -62,6 +63,86 @@ public static void SetBinding(this BindableObject self, BindableProperty targetP
self.SetBinding(targetProperty, binding);
}
+#nullable enable
+ ///
+ /// Creates a binding between a property on the source object and a property on the target object.
+ ///
+ ///
+ /// The following example illustrates the setting of a binding using the extension method.
+ ///
+ /// vm.Name);
+ /// label.BindingContext = vm;
+ ///
+ /// vm.Name = "Jane Doe";
+ /// Debug.WriteLine(label.Text); // prints "Jane Doe"
+ /// ]]>
+ ///
+ /// Not all methods can be used to define a binding. The expression must be a simple property access expression. The following are examples of valid and invalid expressions:
+ ///
+ /// vm.Name;
+ /// static (PersonViewModel vm) => vm.Address?.Street;
+ ///
+ /// // Valid: Array and indexer access
+ /// static (PersonViewModel vm) => vm.PhoneNumbers[0];
+ /// static (PersonViewModel vm) => vm.Config["Font"];
+ ///
+ /// // Valid: Casts
+ /// static (Label label) => (label.BindingContext as PersonViewModel).Name;
+ /// static (Label label) => ((PersonViewModel)label.BindingContext).Name;
+ ///
+ /// // Invalid: Method calls
+ /// static (PersonViewModel vm) => vm.GetAddress();
+ /// static (PersonViewModel vm) => vm.Address?.ToString();
+ ///
+ /// // Invalid: Complex expressions
+ /// static (PersonViewModel vm) => vm.Address?.Street + " " + vm.Address?.City;
+ /// static (PersonViewModel vm) => $"Name: {vm.Name}";
+ /// ]]>
+ ///
+ ///
+ /// The source type.
+ /// The property type.
+ /// The .
+ /// The on which to set a binding.
+ /// An getter method used to retrieve the source property.
+ /// The binding mode. This property is optional. Default is .
+ /// The converter. This parameter is optional. Default is .
+ /// An user-defined parameter to pass to the converter. This parameter is optional. Default is .
+ /// A String format. This parameter is optional. Default is .
+ /// An object used as the source for this binding. This parameter is optional. Default is .
+ /// The value to use instead of the default value for the property, if no specified value exists.
+ /// The value to supply for a bound property when the target of the binding is .
+ ///
+ [EditorBrowsable(EditorBrowsableState.Never)] // TODO: remove the attribute once the source generator is enabled by default
+ public static void SetBinding(
+ this BindableObject self,
+ BindableProperty targetProperty,
+ Func getter,
+ BindingMode mode = BindingMode.Default,
+ IValueConverter? converter = null,
+ object? converterParameter = null,
+ string? stringFormat = null,
+ object? source = null,
+ object? fallbackValue = null,
+ object? targetNullValue = null)
+ {
+ throw new InvalidOperationException($"Call to SetBinding<{typeof(TSource)}, {typeof(TProperty)}> was not intercepted.");
+ }
+#nullable disable
+
public static T GetPropertyIfSet(this BindableObject bindableObject, BindableProperty bindableProperty, T returnIfNotSet)
{
if (bindableObject == null)
diff --git a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt
index ee840e30a0a3..4c8105349500 100644
--- a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt
+++ b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt
@@ -248,4 +248,5 @@ Microsoft.Maui.Controls.Xaml.RequireServiceAttribute
~Microsoft.Maui.Controls.Xaml.RequireServiceAttribute.RequireServiceAttribute(System.Type[] serviceTypes) -> void
~Microsoft.Maui.Controls.Xaml.RequireServiceAttribute.ServiceTypes.get -> System.Type[]
~Microsoft.Maui.Controls.ResourceDictionary.SetAndCreateSource(System.Uri value) -> void
-*REMOVED*~Microsoft.Maui.Controls.ResourceDictionary.SetAndLoadSource(System.Uri value, string resourcePath, System.Reflection.Assembly assembly, System.Xml.IXmlLineInfo lineInfo) -> void
\ No newline at end of file
+*REMOVED*~Microsoft.Maui.Controls.ResourceDictionary.SetAndLoadSource(System.Uri value, string resourcePath, System.Reflection.Assembly assembly, System.Xml.IXmlLineInfo lineInfo) -> void
+static Microsoft.Maui.Controls.BindableObjectExtensions.SetBinding(this Microsoft.Maui.Controls.BindableObject! self, Microsoft.Maui.Controls.BindableProperty! targetProperty, System.Func! getter, Microsoft.Maui.Controls.BindingMode mode = Microsoft.Maui.Controls.BindingMode.Default, Microsoft.Maui.Controls.IValueConverter? converter = null, object? converterParameter = null, string? stringFormat = null, object? source = null, object? fallbackValue = null, object? targetNullValue = null) -> void
diff --git a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt
index ba9f2b5fbe32..d050d5ef499d 100644
--- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt
+++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt
@@ -272,4 +272,5 @@ Microsoft.Maui.Controls.Xaml.RequireServiceAttribute
~Microsoft.Maui.Controls.Xaml.RequireServiceAttribute.RequireServiceAttribute(System.Type[] serviceTypes) -> void
~Microsoft.Maui.Controls.Xaml.RequireServiceAttribute.ServiceTypes.get -> System.Type[]
~Microsoft.Maui.Controls.ResourceDictionary.SetAndCreateSource(System.Uri value) -> void
-*REMOVED*~Microsoft.Maui.Controls.ResourceDictionary.SetAndLoadSource(System.Uri value, string resourcePath, System.Reflection.Assembly assembly, System.Xml.IXmlLineInfo lineInfo) -> void
\ No newline at end of file
+*REMOVED*~Microsoft.Maui.Controls.ResourceDictionary.SetAndLoadSource(System.Uri value, string resourcePath, System.Reflection.Assembly assembly, System.Xml.IXmlLineInfo lineInfo) -> void
+static Microsoft.Maui.Controls.BindableObjectExtensions.SetBinding(this Microsoft.Maui.Controls.BindableObject! self, Microsoft.Maui.Controls.BindableProperty! targetProperty, System.Func! getter, Microsoft.Maui.Controls.BindingMode mode = Microsoft.Maui.Controls.BindingMode.Default, Microsoft.Maui.Controls.IValueConverter? converter = null, object? converterParameter = null, string? stringFormat = null, object? source = null, object? fallbackValue = null, object? targetNullValue = null) -> void
diff --git a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
index d5fa7552a010..03bd016d49ea 100644
--- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
+++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
@@ -273,3 +273,4 @@ Microsoft.Maui.Controls.Xaml.RequireServiceAttribute
~Microsoft.Maui.Controls.Xaml.RequireServiceAttribute.ServiceTypes.get -> System.Type[]
~Microsoft.Maui.Controls.ResourceDictionary.SetAndCreateSource(System.Uri value) -> void
*REMOVED*~Microsoft.Maui.Controls.ResourceDictionary.SetAndLoadSource(System.Uri value, string resourcePath, System.Reflection.Assembly assembly, System.Xml.IXmlLineInfo lineInfo) -> void
+static Microsoft.Maui.Controls.BindableObjectExtensions.SetBinding(this Microsoft.Maui.Controls.BindableObject! self, Microsoft.Maui.Controls.BindableProperty! targetProperty, System.Func! getter, Microsoft.Maui.Controls.BindingMode mode = Microsoft.Maui.Controls.BindingMode.Default, Microsoft.Maui.Controls.IValueConverter? converter = null, object? converterParameter = null, string? stringFormat = null, object? source = null, object? fallbackValue = null, object? targetNullValue = null) -> void
diff --git a/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
index 59a505aaf65c..52330296498c 100644
--- a/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
+++ b/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
@@ -220,4 +220,5 @@ Microsoft.Maui.Controls.Xaml.RequireServiceAttribute
~Microsoft.Maui.Controls.Xaml.RequireServiceAttribute.RequireServiceAttribute(System.Type[] serviceTypes) -> void
~Microsoft.Maui.Controls.Xaml.RequireServiceAttribute.ServiceTypes.get -> System.Type[]
~Microsoft.Maui.Controls.ResourceDictionary.SetAndCreateSource(System.Uri value) -> void
-*REMOVED*~Microsoft.Maui.Controls.ResourceDictionary.SetAndLoadSource(System.Uri value, string resourcePath, System.Reflection.Assembly assembly, System.Xml.IXmlLineInfo lineInfo) -> void
\ No newline at end of file
+*REMOVED*~Microsoft.Maui.Controls.ResourceDictionary.SetAndLoadSource(System.Uri value, string resourcePath, System.Reflection.Assembly assembly, System.Xml.IXmlLineInfo lineInfo) -> void
+static Microsoft.Maui.Controls.BindableObjectExtensions.SetBinding(this Microsoft.Maui.Controls.BindableObject! self, Microsoft.Maui.Controls.BindableProperty! targetProperty, System.Func! getter, Microsoft.Maui.Controls.BindingMode mode = Microsoft.Maui.Controls.BindingMode.Default, Microsoft.Maui.Controls.IValueConverter? converter = null, object? converterParameter = null, string? stringFormat = null, object? source = null, object? fallbackValue = null, object? targetNullValue = null) -> void
diff --git a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt
index 2de90fc857f4..93d38a519a45 100644
--- a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt
+++ b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt
@@ -253,4 +253,5 @@ Microsoft.Maui.Controls.Xaml.RequireServiceAttribute
~Microsoft.Maui.Controls.Xaml.RequireServiceAttribute.RequireServiceAttribute(System.Type[] serviceTypes) -> void
~Microsoft.Maui.Controls.Xaml.RequireServiceAttribute.ServiceTypes.get -> System.Type[]
~Microsoft.Maui.Controls.ResourceDictionary.SetAndCreateSource(System.Uri value) -> void
-*REMOVED*~Microsoft.Maui.Controls.ResourceDictionary.SetAndLoadSource(System.Uri value, string resourcePath, System.Reflection.Assembly assembly, System.Xml.IXmlLineInfo lineInfo) -> void
\ No newline at end of file
+*REMOVED*~Microsoft.Maui.Controls.ResourceDictionary.SetAndLoadSource(System.Uri value, string resourcePath, System.Reflection.Assembly assembly, System.Xml.IXmlLineInfo lineInfo) -> void
+static Microsoft.Maui.Controls.BindableObjectExtensions.SetBinding(this Microsoft.Maui.Controls.BindableObject! self, Microsoft.Maui.Controls.BindableProperty! targetProperty, System.Func! getter, Microsoft.Maui.Controls.BindingMode mode = Microsoft.Maui.Controls.BindingMode.Default, Microsoft.Maui.Controls.IValueConverter? converter = null, object? converterParameter = null, string? stringFormat = null, object? source = null, object? fallbackValue = null, object? targetNullValue = null) -> void
diff --git a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt
index 2482d225d842..334899e1ffa1 100644
--- a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt
+++ b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt
@@ -217,4 +217,5 @@ Microsoft.Maui.Controls.Xaml.RequireServiceAttribute
~Microsoft.Maui.Controls.Xaml.RequireServiceAttribute.RequireServiceAttribute(System.Type[] serviceTypes) -> void
~Microsoft.Maui.Controls.Xaml.RequireServiceAttribute.ServiceTypes.get -> System.Type[]
~Microsoft.Maui.Controls.ResourceDictionary.SetAndCreateSource(System.Uri value) -> void
-*REMOVED*~Microsoft.Maui.Controls.ResourceDictionary.SetAndLoadSource(System.Uri value, string resourcePath, System.Reflection.Assembly assembly, System.Xml.IXmlLineInfo lineInfo) -> void
\ No newline at end of file
+*REMOVED*~Microsoft.Maui.Controls.ResourceDictionary.SetAndLoadSource(System.Uri value, string resourcePath, System.Reflection.Assembly assembly, System.Xml.IXmlLineInfo lineInfo) -> void
+static Microsoft.Maui.Controls.BindableObjectExtensions.SetBinding(this Microsoft.Maui.Controls.BindableObject! self, Microsoft.Maui.Controls.BindableProperty! targetProperty, System.Func! getter, Microsoft.Maui.Controls.BindingMode mode = Microsoft.Maui.Controls.BindingMode.Default, Microsoft.Maui.Controls.IValueConverter? converter = null, object? converterParameter = null, string? stringFormat = null, object? source = null, object? fallbackValue = null, object? targetNullValue = null) -> void
diff --git a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt
index 15af715f42f6..212b98d66c3e 100644
--- a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt
+++ b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt
@@ -218,3 +218,4 @@ Microsoft.Maui.Controls.ContentPage.HideSoftInputOnTapped.set -> void
*REMOVED*Microsoft.Maui.Controls.Entry.SelectionLength.set -> void
~Microsoft.Maui.Controls.ResourceDictionary.SetAndCreateSource(System.Uri value) -> void
*REMOVED*~Microsoft.Maui.Controls.ResourceDictionary.SetAndLoadSource(System.Uri value, string resourcePath, System.Reflection.Assembly assembly, System.Xml.IXmlLineInfo lineInfo) -> void
+static Microsoft.Maui.Controls.BindableObjectExtensions.SetBinding(this Microsoft.Maui.Controls.BindableObject! self, Microsoft.Maui.Controls.BindableProperty! targetProperty, System.Func! getter, Microsoft.Maui.Controls.BindingMode mode = Microsoft.Maui.Controls.BindingMode.Default, Microsoft.Maui.Controls.IValueConverter? converter = null, object? converterParameter = null, string? stringFormat = null, object? source = null, object? fallbackValue = null, object? targetNullValue = null) -> void
diff --git a/src/Controls/tests/BindingSourceGen.UnitTests/AccessExpressionBuilderTests.cs b/src/Controls/tests/BindingSourceGen.UnitTests/AccessExpressionBuilderTests.cs
new file mode 100644
index 000000000000..284dc963bac3
--- /dev/null
+++ b/src/Controls/tests/BindingSourceGen.UnitTests/AccessExpressionBuilderTests.cs
@@ -0,0 +1,58 @@
+using Microsoft.Maui.Controls.BindingSourceGen;
+using Xunit;
+
+namespace BindingSourceGen.UnitTests;
+
+public class AccessExpressionBuilderTests
+{
+ [Fact]
+ public void CorrectlyFormatsSimpleCast()
+ {
+ var generatedCode = Build("source",
+ [
+ new MemberAccess("A"),
+ new Cast(new TypeDescription("X", IsNullable: false, IsGenericParameter: false, IsValueType: false)),
+ new ConditionalAccess(new MemberAccess("B")),
+ ]);
+
+ Assert.Equal("(source.A as X)?.B", generatedCode);
+ }
+
+ [Fact]
+ public void CorrectlyFormatsSimpleCastOfNonNullableValueTypes()
+ {
+ var generatedCode = Build("source",
+ [
+ new MemberAccess("A"),
+ new Cast(new TypeDescription("X", IsNullable: false, IsGenericParameter: false, IsValueType: true)),
+ new ConditionalAccess(new MemberAccess("B")),
+ ]);
+
+ Assert.Equal("(source.A as X?)?.B", generatedCode);
+ }
+
+ [Fact]
+ public void CorrectlyFormatsSimpleCastOfNullableValueTypes()
+ {
+ var generatedCode = Build("source",
+ [
+ new MemberAccess("A"),
+ new Cast(new TypeDescription("X", IsNullable: true, IsGenericParameter: false, IsValueType: true)),
+ new ConditionalAccess(new MemberAccess("B")),
+ ]);
+
+ Assert.Equal("(source.A as X?)?.B", generatedCode);
+ }
+
+ private static string Build(string initialExpression, IPathPart[] path)
+ {
+ string expression = initialExpression;
+
+ foreach (var part in path)
+ {
+ expression = AccessExpressionBuilder.ExtendExpression(expression, part);
+ }
+
+ return expression;
+ }
+}
diff --git a/src/Controls/tests/BindingSourceGen.UnitTests/AssertExtensions.cs b/src/Controls/tests/BindingSourceGen.UnitTests/AssertExtensions.cs
new file mode 100644
index 000000000000..0bf594dffdd5
--- /dev/null
+++ b/src/Controls/tests/BindingSourceGen.UnitTests/AssertExtensions.cs
@@ -0,0 +1,52 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.Maui.Controls.BindingSourceGen;
+using Xunit;
+
+namespace BindingSourceGen.UnitTests;
+
+internal static class AssertExtensions
+{
+ internal static void CodeIsEqual(string expectedCode, string actualCode)
+ {
+ var expectedLines = SplitCode(expectedCode);
+ var actualLines = SplitCode(actualCode);
+
+ foreach (var (expectedLine, actualLine) in expectedLines.Zip(actualLines))
+ {
+ Assert.Equal(expectedLine, actualLine);
+ }
+ }
+
+ internal static void BindingsAreEqual(SetBindingInvocationDescription expectedBinding, CodeGeneratorResult codeGeneratorResult)
+ {
+ AssertNoDiagnostics(codeGeneratorResult);
+ Assert.NotNull(codeGeneratorResult.Binding);
+
+ //TODO: Change arrays to custom collections implementing IEquatable
+ Assert.Equal(expectedBinding.Path, codeGeneratorResult.Binding.Path);
+ Assert.Equal(expectedBinding, codeGeneratorResult.Binding);
+ }
+
+ private static IEnumerable SplitCode(string code)
+ => code.Split(Environment.NewLine)
+ .Select(static line => line.Trim())
+ .Where(static line => !string.IsNullOrWhiteSpace(line));
+
+ internal static void AssertNoDiagnostics(CodeGeneratorResult codeGeneratorResult)
+ {
+ AssertNoDiagnostics(codeGeneratorResult.SourceCompilationDiagnostics, "Source compilation");
+ AssertNoDiagnostics(codeGeneratorResult.SourceGeneratorDiagnostics, "Source generator");
+ AssertNoDiagnostics(codeGeneratorResult.GeneratedCodeCompilationDiagnostics, "Generated code compilation");
+ }
+
+ private static void AssertNoDiagnostics(ImmutableArray diagnostics, string name)
+ {
+ if (diagnostics.Any())
+ {
+ var errorMessages = diagnostics.Select(error => error.ToString());
+ throw new Exception($"\n{name} diagnostics: {string.Join(Environment.NewLine, errorMessages)}");
+ }
+ }
+
+}
diff --git a/src/Controls/tests/BindingSourceGen.UnitTests/BindingCodeWriterTests.cs b/src/Controls/tests/BindingSourceGen.UnitTests/BindingCodeWriterTests.cs
new file mode 100644
index 000000000000..328113c184ee
--- /dev/null
+++ b/src/Controls/tests/BindingSourceGen.UnitTests/BindingCodeWriterTests.cs
@@ -0,0 +1,486 @@
+using System.Linq;
+using Microsoft.Maui.Controls.BindingSourceGen;
+using Xunit;
+
+namespace BindingSourceGen.UnitTests;
+
+public class BindingCodeWriterTests
+{
+ [Fact]
+ public void BuildsWholeDocument()
+ {
+ var codeWriter = new BindingCodeWriter();
+ codeWriter.AddBinding(new SetBindingInvocationDescription(
+ Location: new InterceptorLocation(FilePath: @"Path\To\Program.cs", Line: 20, Column: 30),
+ SourceType: new TypeDescription("global::MyNamespace.MySourceClass", IsValueType: false, IsNullable: false, IsGenericParameter: false),
+ PropertyType: new TypeDescription("global::MyNamespace.MyPropertyClass", IsValueType: false, IsNullable: false, IsGenericParameter: false),
+ Path: new EquatableArray([
+ new MemberAccess("A"),
+ new ConditionalAccess(new MemberAccess("B")),
+ new ConditionalAccess(new MemberAccess("C")),
+ ]),
+ SetterOptions: new(IsWritable: true, AcceptsNullValue: false),
+ NullableContextEnabled: true));
+
+ var code = codeWriter.GenerateCode();
+ AssertExtensions.CodeIsEqual(
+ $$"""
+ //------------------------------------------------------------------------------
+ //
+ // This code was generated by a .NET MAUI source generator.
+ //
+ // Changes to this file may cause incorrect behavior and will be lost if
+ // the code is regenerated.
+ //
+ //------------------------------------------------------------------------------
+ #nullable enable
+
+ namespace System.Runtime.CompilerServices
+ {
+ using System;
+ using System.CodeDom.Compiler;
+
+ {{BindingCodeWriter.GeneratedCodeAttribute}}
+ [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+ file sealed class InterceptsLocationAttribute : Attribute
+ {
+ public InterceptsLocationAttribute(string filePath, int line, int column)
+ {
+ FilePath = filePath;
+ Line = line;
+ Column = column;
+ }
+
+ public string FilePath { get; }
+ public int Line { get; }
+ public int Column { get; }
+ }
+ }
+
+ namespace Microsoft.Maui.Controls.Generated
+ {
+ using System;
+ using System.CodeDom.Compiler;
+ using System.Runtime.CompilerServices;
+ using Microsoft.Maui.Controls.Internals;
+
+ {{BindingCodeWriter.GeneratedCodeAttribute}}
+ file static class GeneratedBindableObjectExtensions
+ {
+
+ {{BindingCodeWriter.GeneratedCodeAttribute}}
+ [InterceptsLocationAttribute(@"Path\To\Program.cs", 20, 30)]
+ public static void SetBinding1(
+ this BindableObject bindableObject,
+ BindableProperty bindableProperty,
+ Func getter,
+ BindingMode mode = BindingMode.Default,
+ IValueConverter? converter = null,
+ object? converterParameter = null,
+ string? stringFormat = null,
+ object? source = null,
+ object? fallbackValue = null,
+ object? targetNullValue = null)
+ {
+ Action? setter = null;
+ if (ShouldUseSetter(mode, bindableProperty))
+ {
+ setter = static (source, value) =>
+ {
+ if (source.A is {} p0
+ && p0.B is {} p1)
+ {
+ p1.C = value;
+ }
+ };
+ }
+
+ var binding = new TypedBinding(
+ getter: source => (getter(source), true),
+ setter,
+ handlers: new Tuple, string>[]
+ {
+ new(static source => source, "A"),
+ new(static source => source.A, "B"),
+ new(static source => source.A?.B, "C"),
+ })
+ {
+ Mode = mode,
+ Converter = converter,
+ ConverterParameter = converterParameter,
+ StringFormat = stringFormat,
+ Source = source,
+ FallbackValue = fallbackValue,
+ TargetNullValue = targetNullValue
+ };
+ bindableObject.SetBinding(bindableProperty, binding);
+ }
+
+ private static bool ShouldUseSetter(BindingMode mode, BindableProperty bindableProperty)
+ => mode == BindingMode.OneWayToSource
+ || mode == BindingMode.TwoWay
+ || (mode == BindingMode.Default
+ && (bindableProperty.DefaultBindingMode == BindingMode.OneWayToSource
+ || bindableProperty.DefaultBindingMode == BindingMode.TwoWay));
+ }
+ }
+ """,
+ code);
+ }
+
+ [Fact]
+ public void CorrectlyFormatsSimpleBinding()
+ {
+ var codeBuilder = new BindingCodeWriter.BindingInterceptorCodeBuilder();
+ codeBuilder.AppendSetBindingInterceptor(id: 1, new SetBindingInvocationDescription(
+ Location: new InterceptorLocation(FilePath: @"Path\To\Program.cs", Line: 20, Column: 30),
+ SourceType: new TypeDescription("global::MyNamespace.MySourceClass", IsValueType: false, IsNullable: false, IsGenericParameter: false),
+ PropertyType: new TypeDescription("global::MyNamespace.MyPropertyClass", IsValueType: false, IsNullable: false, IsGenericParameter: false),
+ Path: new EquatableArray([
+ new MemberAccess("A"),
+ new ConditionalAccess(new MemberAccess("B")),
+ new ConditionalAccess(new MemberAccess("C")),
+ ]),
+ SetterOptions: new(IsWritable: true, AcceptsNullValue: false),
+ NullableContextEnabled: true));
+
+ var code = codeBuilder.ToString();
+ AssertExtensions.CodeIsEqual(
+ $$"""
+ {{BindingCodeWriter.GeneratedCodeAttribute}}
+ [InterceptsLocationAttribute(@"Path\To\Program.cs", 20, 30)]
+ public static void SetBinding1(
+ this BindableObject bindableObject,
+ BindableProperty bindableProperty,
+ Func getter,
+ BindingMode mode = BindingMode.Default,
+ IValueConverter? converter = null,
+ object? converterParameter = null,
+ string? stringFormat = null,
+ object? source = null,
+ object? fallbackValue = null,
+ object? targetNullValue = null)
+ {
+ Action? setter = null;
+ if (ShouldUseSetter(mode, bindableProperty))
+ {
+ setter = static (source, value) =>
+ {
+ if (source.A is {} p0
+ && p0.B is {} p1)
+ {
+ p1.C = value;
+ }
+ };
+ }
+
+ var binding = new TypedBinding(
+ getter: source => (getter(source), true),
+ setter,
+ handlers: new Tuple, string>[]
+ {
+ new(static source => source, "A"),
+ new(static source => source.A, "B"),
+ new(static source => source.A?.B, "C"),
+ })
+ {
+ Mode = mode,
+ Converter = converter,
+ ConverterParameter = converterParameter,
+ StringFormat = stringFormat,
+ Source = source,
+ FallbackValue = fallbackValue,
+ TargetNullValue = targetNullValue
+ };
+
+ bindableObject.SetBinding(bindableProperty, binding);
+ }
+ """,
+ code);
+ }
+
+ [Fact]
+ public void CorrectlyFormatsBindingWithoutAnyNullablesInPath()
+ {
+ var codeBuilder = new BindingCodeWriter.BindingInterceptorCodeBuilder();
+ codeBuilder.AppendSetBindingInterceptor(id: 1, new SetBindingInvocationDescription(
+ Location: new InterceptorLocation(FilePath: @"Path\To\Program.cs", Line: 20, Column: 30),
+ SourceType: new TypeDescription("global::MyNamespace.MySourceClass", IsValueType: false, IsNullable: false, IsGenericParameter: false),
+ PropertyType: new TypeDescription("global::MyNamespace.MyPropertyClass", IsValueType: false, IsNullable: false, IsGenericParameter: false),
+ Path: new EquatableArray([
+ new MemberAccess("A"),
+ new MemberAccess("B"),
+ new MemberAccess("C"),
+ ]),
+ SetterOptions: new(IsWritable: true, AcceptsNullValue: false),
+ NullableContextEnabled: true));
+
+ var code = codeBuilder.ToString();
+ AssertExtensions.CodeIsEqual(
+ $$"""
+ {{BindingCodeWriter.GeneratedCodeAttribute}}
+ [InterceptsLocationAttribute(@"Path\To\Program.cs", 20, 30)]
+ public static void SetBinding1(
+ this BindableObject bindableObject,
+ BindableProperty bindableProperty,
+ Func getter,
+ BindingMode mode = BindingMode.Default,
+ IValueConverter? converter = null,
+ object? converterParameter = null,
+ string? stringFormat = null,
+ object? source = null,
+ object? fallbackValue = null,
+ object? targetNullValue = null)
+ {
+ Action? setter = null;
+ if (ShouldUseSetter(mode, bindableProperty))
+ {
+ setter = static (source, value) =>
+ {
+ source.A.B.C = value;
+ };
+ }
+
+ var binding = new TypedBinding(
+ getter: source => (getter(source), true),
+ setter,
+ handlers: new Tuple, string>[]
+ {
+ new(static source => source, "A"),
+ new(static source => source.A, "B"),
+ new(static source => source.A.B, "C"),
+ })
+ {
+ Mode = mode,
+ Converter = converter,
+ ConverterParameter = converterParameter,
+ StringFormat = stringFormat,
+ Source = source,
+ FallbackValue = fallbackValue,
+ TargetNullValue = targetNullValue
+ };
+
+ bindableObject.SetBinding(bindableProperty, binding);
+ }
+ """,
+ code);
+ }
+
+ [Fact]
+ public void CorrectlyFormatsBindingWithoutSetter()
+ {
+ var codeBuilder = new BindingCodeWriter.BindingInterceptorCodeBuilder();
+ codeBuilder.AppendSetBindingInterceptor(id: 1, new SetBindingInvocationDescription(
+ Location: new InterceptorLocation(FilePath: @"Path\To\Program.cs", Line: 20, Column: 30),
+ SourceType: new TypeDescription("global::MyNamespace.MySourceClass", IsNullable: false, IsGenericParameter: false, IsValueType: false),
+ PropertyType: new TypeDescription("global::MyNamespace.MyPropertyClass", IsNullable: false, IsGenericParameter: false, IsValueType: false),
+ Path: new EquatableArray([
+ new MemberAccess("A"),
+ new MemberAccess("B"),
+ new MemberAccess("C"),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true));
+
+ var code = codeBuilder.ToString();
+ AssertExtensions.CodeIsEqual(
+ $$"""
+ {{BindingCodeWriter.GeneratedCodeAttribute}}
+ [InterceptsLocationAttribute(@"Path\To\Program.cs", 20, 30)]
+ public static void SetBinding1(
+ this BindableObject bindableObject,
+ BindableProperty bindableProperty,
+ Func getter,
+ BindingMode mode = BindingMode.Default,
+ IValueConverter? converter = null,
+ object? converterParameter = null,
+ string? stringFormat = null,
+ object? source = null,
+ object? fallbackValue = null,
+ object? targetNullValue = null)
+ {
+ Action? setter = null;
+ if (ShouldUseSetter(mode, bindableProperty))
+ {
+ throw new InvalidOperationException("Cannot set value on the source object.");
+ }
+
+ var binding = new TypedBinding(
+ getter: source => (getter(source), true),
+ setter,
+ handlers: new Tuple, string>[]
+ {
+ new(static source => source, "A"),
+ new(static source => source.A, "B"),
+ new(static source => source.A.B, "C"),
+ })
+ {
+ Mode = mode,
+ Converter = converter,
+ ConverterParameter = converterParameter,
+ StringFormat = stringFormat,
+ Source = source,
+ FallbackValue = fallbackValue,
+ TargetNullValue = targetNullValue
+ };
+
+ bindableObject.SetBinding(bindableProperty, binding);
+ }
+ """,
+ code);
+ }
+
+ [Fact]
+ public void CorrectlyFormatsBindingWithIndexers()
+ {
+ var codeBuilder = new BindingCodeWriter.BindingInterceptorCodeBuilder();
+ codeBuilder.AppendSetBindingInterceptor(id: 1, new SetBindingInvocationDescription(
+ Location: new InterceptorLocation(FilePath: @"Path\To\Program.cs", Line: 20, Column: 30),
+ SourceType: new TypeDescription("global::MyNamespace.MySourceClass", IsNullable: false, IsGenericParameter: false),
+ PropertyType: new TypeDescription("global::MyNamespace.MyPropertyClass", IsNullable: true, IsGenericParameter: false),
+ Path: new EquatableArray([
+ new IndexAccess("Item", 12),
+ new ConditionalAccess(new IndexAccess("Indexer", "Abc")),
+ new IndexAccess("Item", 0),
+ ]),
+ SetterOptions: new(IsWritable: true, AcceptsNullValue: false),
+ NullableContextEnabled: true));
+
+ var code = codeBuilder.ToString();
+ AssertExtensions.CodeIsEqual(
+ $$"""
+ {{BindingCodeWriter.GeneratedCodeAttribute}}
+ [InterceptsLocationAttribute(@"Path\To\Program.cs", 20, 30)]
+ public static void SetBinding1(
+ this BindableObject bindableObject,
+ BindableProperty bindableProperty,
+ Func getter,
+ BindingMode mode = BindingMode.Default,
+ IValueConverter? converter = null,
+ object? converterParameter = null,
+ string? stringFormat = null,
+ object? source = null,
+ object? fallbackValue = null,
+ object? targetNullValue = null)
+ {
+ Action? setter = null;
+ if (ShouldUseSetter(mode, bindableProperty))
+ {
+ setter = static (source, value) =>
+ {
+ if (value is null)
+ {
+ return;
+ }
+
+ if (source[12] is {} p0)
+ {
+ p0["Abc"][0] = value;
+ }
+ };
+ }
+
+ var binding = new TypedBinding(
+ getter: source => (getter(source), true),
+ setter,
+ handlers: new Tuple, string>[]
+ {
+ new(static source => source, "Item[12]"),
+ new(static source => source[12], "Indexer[Abc]"),
+ new(static source => source[12]?["Abc"], "Item[0]"),
+ })
+ {
+ Mode = mode,
+ Converter = converter,
+ ConverterParameter = converterParameter,
+ StringFormat = stringFormat,
+ Source = source,
+ FallbackValue = fallbackValue,
+ TargetNullValue = targetNullValue
+ };
+
+ bindableObject.SetBinding(bindableProperty, binding);
+ }
+ """,
+ code);
+ }
+
+ [Fact]
+ public void CorrectlyFormatsBindingWithCasts()
+ {
+ var codeBuilder = new BindingCodeWriter.BindingInterceptorCodeBuilder();
+ codeBuilder.AppendSetBindingInterceptor(id: 1, new SetBindingInvocationDescription(
+ Location: new InterceptorLocation(FilePath: @"Path\To\Program.cs", Line: 20, Column: 30),
+ SourceType: new TypeDescription("global::MyNamespace.MySourceClass", IsNullable: false, IsGenericParameter: false),
+ PropertyType: new TypeDescription("global::MyNamespace.MyPropertyClass", IsNullable: false, IsGenericParameter: false),
+ Path: new EquatableArray([
+ new MemberAccess("A"),
+ new Cast(new TypeDescription("X", IsValueType: false, IsNullable: false, IsGenericParameter: false)),
+ new ConditionalAccess(new MemberAccess("B")),
+ new Cast(new TypeDescription("Y", IsValueType: false, IsNullable: false, IsGenericParameter: false)),
+ new ConditionalAccess(new MemberAccess("C")),
+ new Cast(new TypeDescription("Z", IsValueType: true, IsNullable: true, IsGenericParameter: false)),
+ new ConditionalAccess(new MemberAccess("D")),
+ ]),
+ SetterOptions: new(IsWritable: true, AcceptsNullValue: false),
+ NullableContextEnabled: true));
+
+ var code = codeBuilder.ToString();
+
+ AssertExtensions.CodeIsEqual(
+ $$"""
+ {{BindingCodeWriter.GeneratedCodeAttribute}}
+ [InterceptsLocationAttribute(@"Path\To\Program.cs", 20, 30)]
+ public static void SetBinding1(
+ this BindableObject bindableObject,
+ BindableProperty bindableProperty,
+ Func getter,
+ BindingMode mode = BindingMode.Default,
+ IValueConverter? converter = null,
+ object? converterParameter = null,
+ string? stringFormat = null,
+ object? source = null,
+ object? fallbackValue = null,
+ object? targetNullValue = null)
+ {
+ Action? setter = null;
+ if (ShouldUseSetter(mode, bindableProperty))
+ {
+ setter = static (source, value) =>
+ {
+ if (source.A is X p0
+ && p0.B is Y p1
+ && p1.C is Z p2)
+ {
+ p2.D = value;
+ }
+ };
+ }
+
+ var binding = new TypedBinding(
+ getter: source => (getter(source), true),
+ setter,
+ handlers: new Tuple, string>[]
+ {
+ new(static source => source, "A"),
+ new(static source => (source.A as X), "B"),
+ new(static source => ((source.A as X)?.B as Y), "C"),
+ new(static source => (((source.A as X)?.B as Y)?.C as Z?), "D"),
+ })
+ {
+ Mode = mode,
+ Converter = converter,
+ ConverterParameter = converterParameter,
+ StringFormat = stringFormat,
+ Source = source,
+ FallbackValue = fallbackValue,
+ TargetNullValue = targetNullValue
+ };
+
+ bindableObject.SetBinding(bindableProperty, binding);
+ }
+ """,
+ code);
+ }
+
+}
diff --git a/src/Controls/tests/BindingSourceGen.UnitTests/BindingRepresentationGenTests.cs b/src/Controls/tests/BindingSourceGen.UnitTests/BindingRepresentationGenTests.cs
new file mode 100644
index 000000000000..c3690095330c
--- /dev/null
+++ b/src/Controls/tests/BindingSourceGen.UnitTests/BindingRepresentationGenTests.cs
@@ -0,0 +1,1004 @@
+using Microsoft.Maui.Controls.BindingSourceGen;
+using Xunit;
+
+
+namespace BindingSourceGen.UnitTests;
+
+
+public class BindingRepresentationGenTests
+{
+ [Fact]
+ public void GenerateSimpleBinding()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (string s) => s.Length);
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("string"),
+ new TypeDescription("int", IsValueType: true),
+ new EquatableArray([
+ new MemberAccess("Length", IsValueType: true),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWithNestedProperties()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Button b) => b.Text?.Length);
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Microsoft.Maui.Controls.Button"),
+ new TypeDescription("int", IsValueType: true, IsNullable: true),
+ new EquatableArray([
+ new MemberAccess("Text"),
+ new ConditionalAccess(new MemberAccess("Length", IsValueType: true)),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWithNullableReferenceElementInPathWhenNullableEnabled()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f.Button?.Text?.Length);
+
+ class Foo
+ {
+ public Button? Button { get; set; }
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("int", IsValueType: true, IsNullable: true),
+ new EquatableArray([
+ new MemberAccess("Button"),
+ new ConditionalAccess(new MemberAccess("Text")),
+ new ConditionalAccess(new MemberAccess("Length", IsValueType: true)),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+
+ }
+
+ [Fact]
+ public void GenerateBindingWithNullableReferenceSourceWhenNullableEnabled()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Button? b) => b?.Text?.Length);
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Microsoft.Maui.Controls.Button", IsNullable: true),
+ new TypeDescription("int", IsValueType: true, IsNullable: true),
+ new EquatableArray([
+ new ConditionalAccess(new MemberAccess("Text")),
+ new ConditionalAccess(new MemberAccess("Length", IsValueType: true)),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWithNullableValueTypeWhenNullableEnabled()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f.Value);
+
+ class Foo
+ {
+ public int? Value { get; set; }
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("int", IsValueType: true, IsNullable: true),
+ new EquatableArray([
+ new MemberAccess("Value", IsValueType: true),
+ ]),
+ SetterOptions: new(IsWritable: true, AcceptsNullValue: true),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWithNullableSourceReferenceAndNullableReferenceElementInPathWhenNullableEnabled()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Button? b) => b?.Text?.Length);
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Microsoft.Maui.Controls.Button", IsNullable: true),
+ new TypeDescription("int", IsValueType: true, IsNullable: true),
+ new EquatableArray([
+ new ConditionalAccess(new MemberAccess("Text")),
+ new ConditionalAccess(new MemberAccess("Length", IsValueType: true)),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWithNullablePropertyReferenceWhenNullableEnabled()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f.Value);
+
+ class Foo
+ {
+ public string? Value { get; set; }
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("string", IsNullable: true),
+ new EquatableArray([
+ new MemberAccess("Value"),
+ ]),
+ SetterOptions: new(IsWritable: true, AcceptsNullValue: true),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWithNullableReferenceTypesWhenNullableDisabledAndConditionalAccessOperator()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ #nullable disable
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f?.Bar?.Length);
+
+ class Foo
+ {
+ public string Bar { get; set; }
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 4, 7),
+ new TypeDescription("global::Foo", IsNullable: true),
+ new TypeDescription("int", IsValueType: true, IsNullable: true),
+ new EquatableArray([
+ new ConditionalAccess(new MemberAccess("Bar")),
+ new ConditionalAccess(new MemberAccess("Length", IsValueType: true)),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: false);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenNullableDisabledAndPropertyNonNullableValueType()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ #nullable disable
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f.Bar.Length);
+
+ class Foo
+ {
+ public Bar Bar { get; set; }
+ }
+
+ class Bar
+ {
+ public int Length { get; set; }
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 4, 7),
+ new TypeDescription("global::Foo", IsNullable: true),
+ new TypeDescription("int", IsValueType: true),
+ new EquatableArray([
+ new MemberAccess("Bar"),
+ new MemberAccess("Length", IsValueType: true),
+ ]),
+ SetterOptions: new(IsWritable: true),
+ NullableContextEnabled: false);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenNullableDisabledAndPropertyNullableValueType()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ #nullable disable
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f.Bar.Length);
+
+ class Foo
+ {
+ public Bar Bar { get; set; }
+ }
+
+ class Bar
+ {
+ public int? Length { get; set; }
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 4, 7),
+ new TypeDescription("global::Foo", IsNullable: true),
+ new TypeDescription("int", IsNullable: true, IsValueType: true),
+ new EquatableArray([
+ new MemberAccess("Bar"),
+ new MemberAccess("Length", IsValueType: true),
+ ]),
+ SetterOptions: new(IsWritable: true, AcceptsNullValue: true),
+ NullableContextEnabled: false);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenNullableDisabledAndPropertyReferenceType()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ #nullable disable
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f.Bar.Length);
+
+ class Foo
+ {
+ public Bar Bar { get; set; }
+ }
+
+ class Bar
+ {
+ public CustomLength Length { get; set; }
+ }
+
+ class CustomLength
+ {
+
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 4, 7),
+ new TypeDescription("global::Foo", IsNullable: true),
+ new TypeDescription("global::CustomLength", IsNullable: true),
+ new EquatableArray([
+ new MemberAccess("Bar"),
+ new MemberAccess("Length"),
+ ]),
+ SetterOptions: new(IsWritable: true, AcceptsNullValue: true),
+ NullableContextEnabled: false);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenBindingContainsIntegerIndexing()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f.Items[0].Length);
+
+ class Foo
+ {
+ public string[] Items { get; set; } = { "Item1" };
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("int", IsValueType: true),
+ new EquatableArray([
+ new MemberAccess("Items"),
+ new IndexAccess("Item", 0),
+ new MemberAccess("Length", IsValueType: true),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenGetterContainsStringIndexing()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ using System.Collections.Generic;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f.Items["key"].Length);
+
+ class Foo
+ {
+ public Dictionary Items { get; set; } = new();
+ }
+ """;
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 4, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("int", IsValueType: true),
+ new EquatableArray([
+ new MemberAccess("Items"),
+ new IndexAccess("Item", "key"),
+ new MemberAccess("Length", IsValueType: true),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenGetterContainsCustomIndexerWithIndexerNameAttribute()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ using System.Runtime.CompilerServices;
+
+ var label = new Label();
+ var foo = new Foo();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f["key"].Length);
+
+ class Foo
+ { [IndexerName("CustomIndexer")]
+ public string this[string key] => key;
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 6, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("int", IsValueType: true),
+ new EquatableArray([
+ new IndexAccess("CustomIndexer", "key"),
+ new MemberAccess("Length", IsValueType: true),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenGetterContainsNullableIndexer()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f["key"]?.Length);
+
+ class Foo
+ {
+ public string? this[string key] => key;
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("int", IsValueType: true, IsNullable: true),
+ new EquatableArray([
+ new IndexAccess("Item", "key"),
+ new ConditionalAccess(new MemberAccess("Length", IsValueType: true)), // TODO: Improve naming so this looks right
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenGetterContainsConditionallyAccessedIndexer()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f.bar?["key"].Length);
+
+ class Foo
+ {
+ public Bar? bar { get; set; }
+ }
+
+ class Bar
+ {
+ public string this[string key] => key;
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("int", IsValueType: true, IsNullable: true),
+ new EquatableArray([
+ new MemberAccess("bar"),
+ new ConditionalAccess(new IndexAccess("Item", "key")),
+ new MemberAccess("Length", IsValueType: true),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenGetterContainsComplexCombinedIndexers()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ using System.Runtime.CompilerServices;
+ using MyNamespace;
+
+ var label = new Label();
+ label.SetBinding(Label.TextProperty, static (MySourceClass s) => (s[12]?["Abc"][0]));
+
+ namespace MyNamespace
+ {
+ public class MySourceClass
+ {
+ public B this[int index] => new B();
+ }
+
+ public class B
+ {
+ [IndexerName("Indexer")]
+ public MyPropertyClass[] this[string index] => [];
+ }
+
+ public class MyPropertyClass
+ {
+
+ }
+
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 6, 7),
+ new TypeDescription("global::MyNamespace.MySourceClass"),
+ new TypeDescription("global::MyNamespace.MyPropertyClass", IsNullable: true),
+ new EquatableArray([
+ new IndexAccess("Item", 12),
+ new ConditionalAccess(new IndexAccess("Indexer", "Abc")),
+ new IndexAccess("Item", 0),
+ ]),
+ SetterOptions: new(IsWritable: true),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenGetterContainsCustomIndexerWithDefaultMemberAttribute()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ using System.Text;
+
+ var label = new Label();
+ var foo = new Foo();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f.s[0]);
+
+ class Foo
+ {
+ public StringBuilder s {get; set;} = new();
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 6, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("char", IsValueType: true),
+ new EquatableArray([
+ new MemberAccess("s"),
+ new IndexAccess("Chars", 0, IsValueType: true),
+ ]),
+ SetterOptions: new(IsWritable: true),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenGetterContainsCustomIndexerWithoutAttributes()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f["key"].Length);
+
+ class Foo
+ {
+ public string this[string key] => key;
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 4, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("int", IsValueType: true),
+ new EquatableArray([
+ new IndexAccess("Item", "key"),
+ new MemberAccess("Length", IsValueType: true),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenGetterContainsSimpleReferenceTypeCast()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f.Value as string);
+
+ class Foo
+ {
+ public object Value { get; set; } = "Value";
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("string", IsNullable: true),
+ new EquatableArray([
+ new MemberAccess("Value"),
+ new Cast(new TypeDescription("string")),
+ ]),
+ SetterOptions: new(IsWritable: true),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenGetterContainsSimpleReferenceTypeExplicitCast()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => (string)f.Value);
+
+ class Foo
+ {
+ public object Value { get; set; } = "Value";
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("string"),
+ new EquatableArray([
+ new MemberAccess("Value"),
+ new Cast(new TypeDescription("string")),
+ ]),
+ SetterOptions: new(IsWritable: true),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenGetterContainsMemberAccessOfCastReferenceType()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => (f.C as C)?.X);
+
+ public class Foo
+ {
+ public object C { get; set; } = new C();
+ }
+
+ class C
+ {
+ public int X { get; set; }
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("int", IsValueType: true, IsNullable: true),
+ new EquatableArray([
+ new MemberAccess("C"),
+ new Cast(new TypeDescription("global::C")),
+ new ConditionalAccess(new MemberAccess("X", IsValueType: true)),
+ ]),
+ SetterOptions: new(IsWritable: true),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenGetterContainsMemberAccessOfExplicitCastReferenceType()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => ((C)f.C).X);
+
+ public class Foo
+ {
+ public object C { get; set; } = new C();
+ }
+
+ class C
+ {
+ public int X { get; set; }
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("int", IsValueType: true),
+ new EquatableArray([
+ new MemberAccess("C"),
+ new Cast(new TypeDescription("global::C")),
+ new MemberAccess("X", IsValueType: true),
+ ]),
+ SetterOptions: new(IsWritable: true),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+
+ [Theory]
+ [InlineData("static (Foo f) => (f.C as C)?.X")]
+ [InlineData("static (Foo f) => ((C?)f.C)?.X")]
+ public void GenerateBindingWhenGetterContainsMemberAccessOfCastNullableReferenceType(string bindingLambda)
+ {
+ var source = $$"""
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, {{bindingLambda}});
+
+ public class Foo
+ {
+ public object? C { get; set; }
+ }
+
+ class C
+ {
+ public int X { get; set; }
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("int", IsNullable: true, IsValueType: true),
+ new EquatableArray([
+ new MemberAccess("C"),
+ new Cast(new TypeDescription("global::C")),
+ new ConditionalAccess(new MemberAccess("X", IsValueType: true)),
+ ]),
+ SetterOptions: new(IsWritable: true),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Theory]
+ [InlineData("static (Foo f) => f.Value as int?")]
+ [InlineData("static (Foo f) => (int?)f.Value")]
+ public void GenerateBindingWhenGetterContainsSimpleValueTypeCast(string bindingLambda)
+ {
+ var source = $$"""
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, {{bindingLambda}});
+
+ class Foo
+ {
+ public int Value { get; set; }
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("int", IsNullable: true, IsValueType: true),
+ new EquatableArray([
+ new MemberAccess("Value", IsValueType: true),
+ new Cast(new TypeDescription("int", IsNullable: true, IsValueType: true)),
+ ]),
+ SetterOptions: new(IsWritable: true),
+ NullableContextEnabled: true);
+
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void GenerateBindingWhenGetterContainsSimpleValueTypeExplicitCast()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => (int)f.Value);
+
+ class Foo
+ {
+ public int Value { get; set; }
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("int", IsValueType: true),
+ new EquatableArray([
+ new MemberAccess("Value", IsValueType: true),
+ new Cast(new TypeDescription("int", IsValueType: true)),
+ ]),
+ SetterOptions: new(IsWritable: true),
+ NullableContextEnabled: true);
+
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Theory]
+ [InlineData("static (Foo f) => (f.C as C?)?.X")]
+ [InlineData("static (Foo f) => ((C?)f.C)?.X")]
+ public void GenerateBindingWhenGetterContainsMemberAccessOfCastNullableValueType(string bindingLambda)
+ {
+ var source = $$"""
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, {{bindingLambda}});
+
+ public class Foo
+ {
+ public object? C { get; set; }
+ }
+
+ struct C
+ {
+ public int X { get; set; }
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("int", IsNullable: true, IsValueType: true),
+ new EquatableArray([
+ new MemberAccess("C"),
+ new Cast(new TypeDescription("global::C", IsNullable: true, IsValueType: true)),
+ new ConditionalAccess(new MemberAccess("X", IsValueType: true)),
+ ]),
+ SetterOptions: new(IsWritable: true),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void SetsIsWritableFalseWhenPropertyComesFromImmutableCollection()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f.S[0]);
+
+ class Foo
+ {
+ public string S { get; set; } = "Value";
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("char", IsValueType: true),
+ new EquatableArray([
+ new MemberAccess("S"),
+ new IndexAccess("Chars", 0, IsValueType: true),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void SetsIsWritableTrueWhenPropertyComesFromMutableCollection()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f.S[0]);
+
+ class Foo
+ {
+ public char[] S { get; set; } = { 'A' };
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("char", IsValueType: true),
+ new EquatableArray([
+ new MemberAccess("S"),
+ new IndexAccess("Item", 0, IsValueType: true),
+ ]),
+ SetterOptions: new(IsWritable: true),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void SetsIsWritableFalseWhenCustomIndexerHasNoSetter()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f["key"]);
+
+ class Foo
+ {
+ public string this[string key] => key;
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("string"),
+ new EquatableArray([
+ new IndexAccess("Item", "key"),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void SetsIsWritableTrueWhenCustomIndexerHasSetter()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo f) => f["key"]);
+
+ class Foo
+ {
+ public string this[string key] { get => key; set {} }
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo"),
+ new TypeDescription("string"),
+ new EquatableArray([
+ new IndexAccess("Item", "key"),
+ ]),
+ SetterOptions: new(IsWritable: true),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+
+ [Fact]
+ public void SetsIsWritableWhenElementAccessIsConditional()
+ {
+ var source = """
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ label.SetBinding(Label.RotationProperty, static (Foo? f) => f?[0]);
+
+ class Foo
+ {
+ public int this[int key] => key;
+ }
+ """;
+
+ var codeGeneratorResult = SourceGenHelpers.Run(source);
+ var expectedBinding = new SetBindingInvocationDescription(
+ new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ new TypeDescription("global::Foo", IsNullable: true),
+ new TypeDescription("int", IsValueType: true, IsNullable: true),
+ new EquatableArray([
+ new ConditionalAccess(new IndexAccess("Item", 0, IsValueType: true)),
+ ]),
+ SetterOptions: new(IsWritable: false),
+ NullableContextEnabled: true);
+
+ AssertExtensions.BindingsAreEqual(expectedBinding, codeGeneratorResult);
+ }
+}
diff --git a/src/Controls/tests/BindingSourceGen.UnitTests/BindingTransformerTests.cs b/src/Controls/tests/BindingSourceGen.UnitTests/BindingTransformerTests.cs
new file mode 100644
index 000000000000..7d38981a56a1
--- /dev/null
+++ b/src/Controls/tests/BindingSourceGen.UnitTests/BindingTransformerTests.cs
@@ -0,0 +1,133 @@
+using Microsoft.Maui.Controls.BindingSourceGen;
+using Xunit;
+
+namespace BindingSourceGen.UnitTests;
+
+public class BindingTransformerTests
+{
+ [Fact]
+ public void WrapMemberAccessInConditionalAccessWhenSourceTypeIsReferenceType()
+ {
+ var binding = new SetBindingInvocationDescription(
+ Location: new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ SourceType: new TypeDescription("MyType", IsValueType: false),
+ PropertyType: new TypeDescription("MyType2"),
+ Path: new EquatableArray([new MemberAccess("A")]),
+ SetterOptions: new SetterOptions(IsWritable: true),
+ NullableContextEnabled: false);
+
+ var transformer = new ReferenceTypesConditionalAccessTransformer();
+ var transformedBinding = transformer.Transform(binding);
+
+ var transformedPath = new EquatableArray([new ConditionalAccess(new MemberAccess("A"))]);
+ Assert.Equal(transformedPath, transformedBinding.Path);
+ }
+
+ [Fact]
+ public void WrapMemberAccessInConditionalAccessWhePreviousPartTypeIsReferenceType()
+ {
+ var binding = new SetBindingInvocationDescription(
+ Location: new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ SourceType: new TypeDescription("MyType", IsValueType: true),
+ PropertyType: new TypeDescription("MyType2"),
+ Path: new EquatableArray(
+ [
+ new MemberAccess("A", IsValueType: false),
+ new MemberAccess("B"),
+ ]),
+ SetterOptions: new SetterOptions(IsWritable: true),
+ NullableContextEnabled: false);
+
+ var transformer = new ReferenceTypesConditionalAccessTransformer();
+ var transformedBinding = transformer.Transform(binding);
+
+ var transformedPath = new EquatableArray(
+ [
+ new MemberAccess("A"),
+ new ConditionalAccess(new MemberAccess("B")),
+ ]);
+ Assert.Equal(transformedPath, transformedBinding.Path);
+ }
+
+ [Fact]
+ public void DoNotWrapMemberAccessInConditionalAccessWhePreviousPartTypeIsValueType()
+ {
+ var binding = new SetBindingInvocationDescription(
+ Location: new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ SourceType: new TypeDescription("MyType", IsValueType: false),
+ PropertyType: new TypeDescription("MyType2"),
+ Path: new EquatableArray(
+ [
+ new MemberAccess("A", IsValueType: true),
+ new MemberAccess("B"),
+ ]),
+ SetterOptions: new SetterOptions(IsWritable: true),
+ NullableContextEnabled: false);
+
+ var transformer = new ReferenceTypesConditionalAccessTransformer();
+ var transformedBinding = transformer.Transform(binding);
+
+ var transformedPath = new EquatableArray(
+ [
+ new ConditionalAccess(new MemberAccess("A", IsValueType: true)),
+ new MemberAccess("B"),
+ ]);
+ Assert.Equal(transformedPath, transformedBinding.Path);
+ }
+
+ [Fact]
+ public void WrapAccessInConditionalAccessWhenAllPartsAreReferenceTypes()
+ {
+ var binding = new SetBindingInvocationDescription(
+ Location: new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ SourceType: new TypeDescription("MyType"),
+ PropertyType: new TypeDescription("MyType2"),
+ Path: new EquatableArray(
+ [
+ new MemberAccess("A"),
+ new IndexAccess("Item", 0),
+ new MemberAccess("B"),
+ ]),
+ SetterOptions: new SetterOptions(IsWritable: true),
+ NullableContextEnabled: false);
+
+ var transformer = new ReferenceTypesConditionalAccessTransformer();
+ var transformedBinding = transformer.Transform(binding);
+
+ var transformedPath = new EquatableArray(
+ [
+ new ConditionalAccess(new MemberAccess("A")),
+ new ConditionalAccess(new IndexAccess("Item", 0)),
+ new ConditionalAccess(new MemberAccess("B")),
+ ]);
+ Assert.Equal(transformedPath, transformedBinding.Path);
+ }
+
+ [Fact]
+ public void DoNotWrapAccessInConditionalAccessWhenNoPartsAreReferenceTypes()
+ {
+ var binding = new SetBindingInvocationDescription(
+ Location: new InterceptorLocation(@"Path\To\Program.cs", 3, 7),
+ SourceType: new TypeDescription("MyType", IsValueType: true),
+ PropertyType: new TypeDescription("MyType2"),
+ Path: new EquatableArray(
+ [
+ new MemberAccess("A", IsValueType: true),
+ new IndexAccess("Item", 0, IsValueType: true),
+ new MemberAccess("B", IsValueType: true),
+ ]),
+ SetterOptions: new SetterOptions(IsWritable: true),
+ NullableContextEnabled: false);
+
+ var transformer = new ReferenceTypesConditionalAccessTransformer();
+ var transformedBinding = transformer.Transform(binding);
+
+ var transformedPath = new EquatableArray(
+ [
+ new MemberAccess("A", IsValueType: true),
+ new IndexAccess("Item", 0, IsValueType: true),
+ new MemberAccess("B", IsValueType: true),
+ ]);
+ Assert.Equal(transformedPath, transformedBinding.Path);
+ }
+}
diff --git a/src/Controls/tests/BindingSourceGen.UnitTests/Controls.BindingSourceGen.UnitTests.csproj b/src/Controls/tests/BindingSourceGen.UnitTests/Controls.BindingSourceGen.UnitTests.csproj
new file mode 100644
index 000000000000..f353f69562f1
--- /dev/null
+++ b/src/Controls/tests/BindingSourceGen.UnitTests/Controls.BindingSourceGen.UnitTests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ $(_MauiDotNetTfm)
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
\ No newline at end of file
diff --git a/src/Controls/tests/BindingSourceGen.UnitTests/DiagnosticsTests.cs b/src/Controls/tests/BindingSourceGen.UnitTests/DiagnosticsTests.cs
new file mode 100644
index 000000000000..a759a90b8e00
--- /dev/null
+++ b/src/Controls/tests/BindingSourceGen.UnitTests/DiagnosticsTests.cs
@@ -0,0 +1,134 @@
+using Xunit;
+
+namespace BindingSourceGen.UnitTests;
+
+public class DiagnosticsTests
+{
+ [Fact]
+ public void ReportsErrorWhenGetterIsNotLambda()
+ {
+ var source = """
+ using System;
+ using Microsoft.Maui.Controls;
+ var label = new Label();
+ var getter = new Func