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(b => b.Text.Length); + label.SetBinding(Label.RotationProperty, getter); + """; + + var result = SourceGenHelpers.Run(source); + Assert.Single(result.SourceGeneratorDiagnostics); + Assert.Equal("BSG0002", result.SourceGeneratorDiagnostics[0].Id); + } + + [Fact] + public void ReportsErrorWhenLambdaBodyIsNotExpression() + { + var source = """ + using Microsoft.Maui.Controls; + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (Button b) => { return b.Text.Length; }); + """; + + var result = SourceGenHelpers.Run(source); + + Assert.Single(result.SourceGeneratorDiagnostics); + Assert.Equal("BSG0003", result.SourceGeneratorDiagnostics[0].Id); + } + + [Fact] + public void DoesNotReportWarningWhenUsingOverloadWithBindingClassDeclaredInInvocation() + { + var source = """ + using Microsoft.Maui.Controls; + var label = new Label(); + var slider = new Slider(); + label.SetBinding(Label.ScaleProperty, new Binding("Value", source: slider)); + """; + + var result = SourceGenHelpers.Run(source); + Assert.Empty(result.SourceGeneratorDiagnostics); + } + + [Fact] + public void DoesNotReportWarningWhenUsingOverloadWithBindingClassPassedAsVariable() + { + var source = """ + using Microsoft.Maui.Controls; + var label = new Label(); + var slider = new Slider(); + var binding = new Binding("Value", source: slider); + label.SetBinding(Label.ScaleProperty, binding); + """; + + var result = SourceGenHelpers.Run(source); + Assert.Empty(result.SourceGeneratorDiagnostics); + } + + [Fact] + public void DoesNotReportWarningWhenUsingOverloadWithStringConstantPath() + { + var source = """ + using Microsoft.Maui.Controls; + var label = new Label(); + var slider = new Slider(); + + label.BindingContext = slider; + label.SetBinding(Label.ScaleProperty, "Value"); + """; + + var result = SourceGenHelpers.Run(source); + Assert.Empty(result.SourceGeneratorDiagnostics); + } + + [Fact] + public void DoesNotReportWarningWhenUsingOverloadWithStringVariablePath() + { + var source = """ + using Microsoft.Maui.Controls; + var label = new Label(); + var slider = new Slider(); + + label.BindingContext = slider; + var str = "Value"; + label.SetBinding(Label.ScaleProperty, str); + """; + + var result = SourceGenHelpers.Run(source); + Assert.Empty(result.SourceGeneratorDiagnostics); + } + + [Fact] + public void ReportsUnableToResolvePathWhenUsingMethodCall() + { + var source = """ + using Microsoft.Maui.Controls; + + double GetRotation(Button b) => b.Rotation; + + var label = new Label(); + label.SetBinding(Label.RotationProperty, (Button b) => GetRotation(b)); + """; + + var result = SourceGenHelpers.Run(source); + + Assert.Single(result.SourceGeneratorDiagnostics); + Assert.Equal("BSG0001", result.SourceGeneratorDiagnostics[0].Id); + } + + [Fact] + public void ReportsUnableToResolvePathWhenUsingMultidimensionalArray() + { + var source = """ + using Microsoft.Maui.Controls; + var label = new Label(); + + var array = new int[1, 1]; + label.SetBinding(Label.RotationProperty, (Button b) => array[0, 0]); + """; + + var result = SourceGenHelpers.Run(source); + + Assert.Single(result.SourceGeneratorDiagnostics); + Assert.Equal("BSG0001", result.SourceGeneratorDiagnostics[0].Id); + } +} diff --git a/src/Controls/tests/BindingSourceGen.UnitTests/IncrementalGenerationTests.cs b/src/Controls/tests/BindingSourceGen.UnitTests/IncrementalGenerationTests.cs new file mode 100644 index 000000000000..f0d12f7c81e1 --- /dev/null +++ b/src/Controls/tests/BindingSourceGen.UnitTests/IncrementalGenerationTests.cs @@ -0,0 +1,166 @@ +using Microsoft.CodeAnalysis; +using Xunit; + +namespace BindingSourceGen.UnitTests; + + +public class IncrementalGenerationTests +{ + [Fact] + public void CompilingTheSameSourceResultsInEqualModels() + { + var source = """ + using Microsoft.Maui.Controls; + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (string s) => s.Length); + """; + + var inputCompilation1 = SourceGenHelpers.CreateCompilation(source); + var driver1 = SourceGenHelpers.CreateDriver(); + var result1 = driver1.RunGenerators(inputCompilation1).GetRunResult().Results.Single(); + + var inputCompilation2 = SourceGenHelpers.CreateCompilation(source); + var driver2 = SourceGenHelpers.CreateDriver(); + var result2 = driver2.RunGenerators(inputCompilation2).GetRunResult().Results.Single(); + + Assert.Equal(result1.TrackedSteps.Count, result2.TrackedSteps.Count); + CompareGeneratorOutputs(result1, result2); + } + + [Fact] + public void DoesNotRegenerateCodeWhenNoChanges() + { + var source = """ + using Microsoft.Maui.Controls; + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (string s) => s.Length); + """; + + RunGeneratorOnTwoSourcesAndVerifyResults([source], [source], reason => Assert.True(reason == IncrementalStepRunReason.Unchanged || reason == IncrementalStepRunReason.Cached)); + } + + [Fact] + public void DoesRegenerateCodeWhenSourceChanged() + { + var source = """ + using Microsoft.Maui.Controls; + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (string s) => s.Length); + """; + + var newSource = """ + using Microsoft.Maui.Controls; + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (string s) => s); + """; + + RunGeneratorOnTwoSourcesAndVerifyResults([source], [newSource], reason => Assert.True(reason == IncrementalStepRunReason.Modified)); + } + + [Fact] + public void DoesRegenerateCodeWhenNewCodeInsertedAbove() + { + var source = """ + using Microsoft.Maui.Controls; + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (string s) => s.Length); + """; + + var newSource = """ + using Microsoft.Maui.Controls; + var label = new Label(); + var x = 42; + label.SetBinding(Label.RotationProperty, static (string s) => s.Length); + """; + + RunGeneratorOnTwoSourcesAndVerifyResults([source], [newSource], reason => Assert.True(reason == IncrementalStepRunReason.Modified)); + } + + [Fact] + public void DoesNotRegenerateCodeWhenNewCodeInsertedBelow() + { + var source = """ + using Microsoft.Maui.Controls; + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (string s) => s.Length); + """; + + var newSource = """ + using Microsoft.Maui.Controls; + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (string s) => s.Length); + + var x = 42; + """; + + RunGeneratorOnTwoSourcesAndVerifyResults([source], [newSource], reason => Assert.True(reason == IncrementalStepRunReason.Unchanged || reason == IncrementalStepRunReason.Cached)); + } + + [Fact] + public void DoesNotRegerateCodeWhenDifferentFileEdited() + { + var fileASource = """ + using Microsoft.Maui.Controls; + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (string s) => s.Length); + """; + + var fileBSource = """ + var x = 42; + """; + + var fileBModified = """ + var x = 43; + """; + + RunGeneratorOnTwoSourcesAndVerifyResults([fileASource, fileBSource], [fileASource, fileBModified], reason => Assert.True(reason == IncrementalStepRunReason.Unchanged || reason == IncrementalStepRunReason.Cached)); + } + + private static void RunGeneratorOnTwoSourcesAndVerifyResults(List sources, List modified, Action assert) + { + var inputCompilation = SourceGenHelpers.CreateCompilation(sources); + var cloneCompilation = inputCompilation.Clone(); + var driver = SourceGenHelpers.CreateDriver(); + + var driverWithCachedInfo = driver.RunGenerators(inputCompilation); + + var result = driverWithCachedInfo.GetRunResult().Results.Single(); + var steps = result.TrackedSteps; + + var reasons = steps.SelectMany(step => step.Value).SelectMany(x => x.Outputs).Select(x => x.Reason); + Assert.All(reasons, reason => Assert.Equal(IncrementalStepRunReason.New, reason)); + + var newCompilation = SourceGenHelpers.CreateCompilation(modified); + var newResult = driverWithCachedInfo.RunGenerators(newCompilation).GetRunResult().Results.Single(); + var newSteps = newResult.TrackedSteps; + + var newReasons = newSteps + .Where(step => SourceGenHelpers.StepsForComparison.Contains(step.Key)) + .SelectMany(step => step.Value) + .SelectMany(x => x.Outputs) + .Select(x => x.Reason); + + Assert.All(newReasons, reason => assert(reason)); + } + + private static void CompareGeneratorOutputs(GeneratorRunResult result1, GeneratorRunResult result2) + { + var stepComparisons = from stepA in result1.TrackedSteps + join stepB in result2.TrackedSteps on stepA.Key equals stepB.Key + where SourceGenHelpers.StepsForComparison.Contains(stepA.Key) + select new { StepA = stepA, StepB = stepB }; + + foreach (var comparison in stepComparisons) + { + var outputsA = comparison.StepA.Value.SelectMany(run => run.Outputs); + var outputsB = comparison.StepB.Value.SelectMany(run => run.Outputs); + + foreach (var (outputA, outputB) in outputsA.Zip(outputsB)) + { + Assert.Equal(outputA.Reason, outputB.Reason); + Assert.Equal(outputA.Value, outputB.Value); + } + } + } +} + diff --git a/src/Controls/tests/BindingSourceGen.UnitTests/IntegrationTests.cs b/src/Controls/tests/BindingSourceGen.UnitTests/IntegrationTests.cs new file mode 100644 index 000000000000..a2a27e43765a --- /dev/null +++ b/src/Controls/tests/BindingSourceGen.UnitTests/IntegrationTests.cs @@ -0,0 +1,1338 @@ +using Microsoft.Maui.Controls.BindingSourceGen; +using Xunit; + +namespace BindingSourceGen.UnitTests; +public class IntegrationTests +{ + [Fact] + public void GenerateSimpleBinding() + { + var source = """ + using Microsoft.Maui.Controls; + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (string s) => s.Length); + """; + + var result = SourceGenHelpers.Run(source); + AssertExtensions.AssertNoDiagnostics(result); + 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", 3, 7)] + 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, "Length"), + }) + { + 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)); + } + } + """, + result.GeneratedCode); + } + + [Fact] + public void GenerateSimpleBindingWhenNullableDisabledNonNullableValueType() + { + var source = """ + #nullable disable + using Microsoft.Maui.Controls; + using MyNamespace; + + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (A a) => a.B.C); + + namespace MyNamespace + { + public class A + { + public B B { get; set; } + } + + public class B + { + public int C { get; set; } + } + } + """; + + var result = SourceGenHelpers.Run(source); + AssertExtensions.AssertNoDiagnostics(result); + 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", 6, 7)] + 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 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, "B"), + new(static source => source?.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)); + } + } + """, + result.GeneratedCode); + } + + [Fact] + public void GenerateSimpleBindingWhenNullableDisabledNonNullableValueTypeWithIndexers() + { + var source = """ + #nullable disable + using Microsoft.Maui.Controls; + using System.Collections.Generic; + using MyNamespace; + + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (A a) => a.B[0].C); + + namespace MyNamespace + { + public class A + { + public List B { get; set; } + } + + public class B + { + public int C { get; set; } + } + } + """; + + var result = SourceGenHelpers.Run(source); + AssertExtensions.AssertNoDiagnostics(result); + 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", 7, 7)] + 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 is {} p0 + && p0.B is {} p1 + && p1[0] is {} p2) + { + p2.C = value; + } + }; + } + + var binding = new TypedBinding( + getter: source => (getter(source), true), + setter, + handlers: new Tuple, string>[] + { + new(static source => source, "B"), + new(static source => source?.B, "Item[0]"), + new(static source => source?.B?[0], "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)); + } + } + """, + result.GeneratedCode); + } + + public static IEnumerable GenerateSimpleBindingWhenNullableDisabledAndPropertyNullableData => + new List + { + new object[] + { + """ + // Nullable value type + #nullable disable + using Microsoft.Maui.Controls; + using MyNamespace; + + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (A a) => a.B.C); + + namespace MyNamespace + { + public class A + { + public B B { get; set; } + } + + public class B + { + public C? C { get; set; } + } + + public struct C + { + + } + } + """ + }, + new object[] + { + """ + // Reference Type + #nullable disable + using Microsoft.Maui.Controls; + using MyNamespace; + + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (A a) => a.B.C); + + namespace MyNamespace + { + public class A + { + public B B { get; set; } + } + + public class B + { + public C C { get; set; } + } + + public class C + { + + } + } + """ + }, + new object[] + { + """ + // Conditional access operator + #nullable disable + using Microsoft.Maui.Controls; + using MyNamespace; + + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (A a) => a?.B.C); + + namespace MyNamespace + { + public class A + { + public B B { get; set; } + } + + public class B + { + public C C { get; set; } + } + + public class C + { + + } + } + """ + }, + new object[] + { + """ + // Nullable value type on path + #nullable disable + using Microsoft.Maui.Controls; + using MyNamespace; + + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (A a) => a.B?.C); + + namespace MyNamespace + { + public class A + { + public B? B { get; set; } + } + + public struct B + { + public C C { get; set; } + } + + public class C + { + + } + } + """ + }, + }; + + [Theory] + [MemberData(nameof(GenerateSimpleBindingWhenNullableDisabledAndPropertyNullableData))] + public void GenerateSimpleBindingWhenNullableDisabledAndPropertyNullable(string source) + { + var result = SourceGenHelpers.Run(source); + AssertExtensions.AssertNoDiagnostics(result); + 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", 7, 7)] + 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 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, "B"), + new(static source => source?.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)); + } + } + """, + result.GeneratedCode); + } + + [Fact] + public void GenerateSimpleBindingWhenNullableDisabledAndNonNullableValueTypeInPath() + { + var source = """ + #nullable disable + using Microsoft.Maui.Controls; + using MyNamespace; + + + var label = new Label(); + label.SetBinding(Label.RotationProperty, static (A a) => a.B.C.D); + + namespace MyNamespace + { + public class A + { + public B B; + } + + public struct B + { + public C C; + + public B() + { + C = null!; + } + } + + public class C + { + public D D { get; set;} + } + + public class D { + + } + } + """; + + var result = SourceGenHelpers.Run(source); + AssertExtensions.AssertNoDiagnostics(result); + 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", 7, 7)] + 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 is {} p0 + && p0.B.C is {} p1) + { + p1.D = value; + } + }; + } + + var binding = new TypedBinding( + getter: source => (getter(source), true), + setter, + handlers: new Tuple, string>[] + { + new(static source => source, "B"), + new(static source => source?.B, "C"), + new(static source => source?.B.C, "D"), + }) + { + 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)); + } + } + """, + result.GeneratedCode); + } + + [Theory] + [InlineData("static (MySourceClass s) => (((s.A as X)?.B as Y)?.C as Z)?.D")] + [InlineData("static (MySourceClass s) => ((Z?)((Y?)((X?)s.A)?.B)?.C)?.D")] + public void GenerateBindingWithNullableReferenceTypesCasts(string bindingLambda) + { + var source = $$""" + using Microsoft.Maui.Controls; + using MyNamespace; + var label = new Label(); + label.SetBinding(Label.TextProperty, {{bindingLambda}}); + + namespace MyNamespace + { + public class MySourceClass + { + public object? A { get; set; } + } + + public class X + { + public object? B { get; set; } + } + + public class Y + { + public object C { get; set; } = null!; + } + + public class Z + { + public MyPropertyClass D { get; set; } = null!; + } + + public class MyPropertyClass + { + } + } + """; + + var result = SourceGenHelpers.Run(source); + + AssertExtensions.AssertNoDiagnostics(result); + 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", 4, 7)] + 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.A is global::MyNamespace.X p0 + && p0.B is global::MyNamespace.Y p1 + && p1.C is global::MyNamespace.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 global::MyNamespace.X), "B"), + new(static source => ((source.A as global::MyNamespace.X)?.B as global::MyNamespace.Y), "C"), + new(static source => (((source.A as global::MyNamespace.X)?.B as global::MyNamespace.Y)?.C as global::MyNamespace.Z), "D"), + }) + { + 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)); + } + } + """, + result.GeneratedCode); + } + + [Fact] + public void GenerateBindingWithNonNullableReferenceTypesCasts() + { + var source = $$""" + using Microsoft.Maui.Controls; + using MyNamespace; + var label = new Label(); + label.SetBinding(Label.TextProperty, static (MySourceClass s) => ((Z)((Y)((X)s.A).B).C).D); + + namespace MyNamespace + { + public class MySourceClass + { + public object A { get; set; } = null!; + } + + public class X + { + public object B { get; set; } = null!; + } + + public class Y + { + public object C { get; set; } = null!; + } + + public class Z + { + public MyPropertyClass D { get; set; } = null!; + } + + public class MyPropertyClass + { + } + } + """; + + var result = SourceGenHelpers.Run(source); + + AssertExtensions.AssertNoDiagnostics(result); + 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", 4, 7)] + 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 global::MyNamespace.X p0 + && p0.B is global::MyNamespace.Y p1 + && p1.C is global::MyNamespace.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 global::MyNamespace.X), "B"), + new(static source => ((source.A as global::MyNamespace.X)?.B as global::MyNamespace.Y), "C"), + new(static source => (((source.A as global::MyNamespace.X)?.B as global::MyNamespace.Y)?.C as global::MyNamespace.Z), "D"), + }) + { + 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)); + } + } + """, + result.GeneratedCode); + } + + [Fact] + public void GenerateBindingWithForcedConditionalAccessAfterCast() + { + var source = $$""" + using Microsoft.Maui.Controls; + var label = new Label(); + label.SetBinding(Label.TextProperty, static (MyNamespace.A n) => ((MyNamespace.Wrapper)n.X).Wrapped.Y.Value.Length); + + namespace MyNamespace + { + public struct A + { + public object X; + public B Y; + } + + public struct B + { + public string Value; + } + + public class Wrapper + { + public A Wrapped; + } + } + """; + + var result = SourceGenHelpers.Run(source); + + AssertExtensions.AssertNoDiagnostics(result); + 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", 3, 7)] + 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, "X"), + new(static source => (source.X as global::MyNamespace.Wrapper), "Wrapped"), + new(static source => (source.X as global::MyNamespace.Wrapper)?.Wrapped, "Y"), + new(static source => (source.X as global::MyNamespace.Wrapper)?.Wrapped.Y, "Value"), + new(static source => (source.X as global::MyNamespace.Wrapper)?.Wrapped.Y.Value, "Length"), + }) + { + 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)); + } + } + """, + result.GeneratedCode); + } + + [Fact] + public void GenerateBindingWithIndexers() + { + 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 result = SourceGenHelpers.Run(source); + + AssertExtensions.AssertNoDiagnostics(result); + 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", 6, 7)] + 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); + } + + 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)); + } + } + """, + result.GeneratedCode); + } +} diff --git a/src/Controls/tests/BindingSourceGen.UnitTests/SetterBuilderTests.cs b/src/Controls/tests/BindingSourceGen.UnitTests/SetterBuilderTests.cs new file mode 100644 index 000000000000..26cdbbe29772 --- /dev/null +++ b/src/Controls/tests/BindingSourceGen.UnitTests/SetterBuilderTests.cs @@ -0,0 +1,111 @@ +using Microsoft.Maui.Controls.BindingSourceGen; +using Xunit; + +namespace BindingSourceGen.UnitTests; + +public class SetterBuilderTests +{ + [Fact] + public void GeneratesSetterWithoutAnyPatternMatchingForEmptyPath() + { + var setter = Setter.From([]); + + Assert.Empty(setter.PatternMatchingExpressions); + Assert.Equal("source = value;", setter.AssignmentStatement); + } + + [Fact] + public void GeneratesSetterWithSourceNotNullPatternMatchingForSinglePathStepWhenSourceTypeIsNullableAndConditionalAccess() + { + var setter = Setter.From([new ConditionalAccess(new MemberAccess("A"))]); + + Assert.Single(setter.PatternMatchingExpressions); + Assert.Equal("source is {} p0", setter.PatternMatchingExpressions[0]); + Assert.Equal("p0.A = value;", setter.AssignmentStatement); + } + + [Fact] + public void GeneratesSetterWithoutAnyPatternMatchingForSignlePathStepWhenSourceTypeIsNotNullable() + { + var setter = Setter.From([new MemberAccess("A")]); + + Assert.Empty(setter.PatternMatchingExpressions); + Assert.Equal("source.A = value;", setter.AssignmentStatement); + } + + [Fact] + public void GeneratesSetterWithCorrectConditionalAccess() + { + var setter = Setter.From( + [ + new MemberAccess("A"), + new ConditionalAccess(new MemberAccess("B")), + new ConditionalAccess(new MemberAccess("C")), + ]); + + Assert.Equal(2, setter.PatternMatchingExpressions.Length); + Assert.Equal("source.A is {} p0", setter.PatternMatchingExpressions[0]); + Assert.Equal("p0.B is {} p1", setter.PatternMatchingExpressions[1]); + Assert.Equal("p1.C = value;", setter.AssignmentStatement); + } + + [Fact] + public void GeneratesSetterWithPatternMatchingWithValueTypeCast1() + { + var setter = Setter.From( + [ + new MemberAccess("A"), + new Cast(new TypeDescription("X", IsValueType: false)), + new ConditionalAccess(new MemberAccess("B")), + new Cast(new TypeDescription("Y", IsValueType: true)), + new ConditionalAccess(new MemberAccess("C")), + new MemberAccess("D"), + ]); + + Assert.Equal(2, setter.PatternMatchingExpressions.Length); + Assert.Equal("source.A is X p0", setter.PatternMatchingExpressions[0]); + Assert.Equal("p0.B is Y p1", setter.PatternMatchingExpressions[1]); + Assert.Equal("p1.C.D = value;", setter.AssignmentStatement); + } + + [Fact] + public void GeneratesSetterWithPatternMatchingWithValueTypeCast2() + { + var setter = Setter.From( + [ + new MemberAccess("A"), + new Cast(new TypeDescription("X", IsValueType: false)), + new ConditionalAccess(new MemberAccess("B")), + new Cast(new TypeDescription("Y", IsValueType: true)), + new ConditionalAccess(new MemberAccess("C")), + new ConditionalAccess(new MemberAccess("D")), + ]); + + Assert.Equal(3, setter.PatternMatchingExpressions.Length); + Assert.Equal("source.A is X p0", setter.PatternMatchingExpressions[0]); + Assert.Equal("p0.B is Y p1", setter.PatternMatchingExpressions[1]); + Assert.Equal("p1.C is {} p2", setter.PatternMatchingExpressions[2]); + Assert.Equal("p2.D = value;", setter.AssignmentStatement); + } + + [Fact] + public void GeneratesSetterWithPatternMatchingWithCastsAndConditionalAccess() + { + var setter = Setter.From( + [ + new MemberAccess("A"), + new Cast(TargetType: new TypeDescription("X", IsValueType: false, IsNullable: false)), + new ConditionalAccess(new MemberAccess("B")), + new Cast(new TypeDescription("Y", IsValueType: false, IsNullable: false)), + new ConditionalAccess(new MemberAccess("C")), + new Cast(new TypeDescription("Z", IsValueType: true, IsNullable: true)), + new ConditionalAccess(new MemberAccess("D")), + ]); + + Assert.Equal(3, setter.PatternMatchingExpressions.Length); + Assert.Equal("source.A is X p0", setter.PatternMatchingExpressions[0]); + Assert.Equal("p0.B is Y p1", setter.PatternMatchingExpressions[1]); + Assert.Equal("p1.C is Z p2", setter.PatternMatchingExpressions[2]); + Assert.Equal("p2.D = value;", setter.AssignmentStatement); + } +} diff --git a/src/Controls/tests/BindingSourceGen.UnitTests/SourceGenHelpers.cs b/src/Controls/tests/BindingSourceGen.UnitTests/SourceGenHelpers.cs new file mode 100644 index 000000000000..45e37bdd9a11 --- /dev/null +++ b/src/Controls/tests/BindingSourceGen.UnitTests/SourceGenHelpers.cs @@ -0,0 +1,79 @@ +using System.Collections.Immutable; +using System.Reflection; +using System.Runtime.Loader; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.Maui.Controls.BindingSourceGen; + + +internal record CodeGeneratorResult( + string GeneratedCode, + ImmutableArray SourceCompilationDiagnostics, + ImmutableArray SourceGeneratorDiagnostics, + ImmutableArray GeneratedCodeCompilationDiagnostics, + SetBindingInvocationDescription? Binding); + +internal static class SourceGenHelpers +{ + private static readonly CSharpParseOptions ParseOptions = new CSharpParseOptions(LanguageVersion.Preview).WithFeatures( + [new KeyValuePair("InterceptorsPreviewNamespaces", "Microsoft.Maui.Controls.Generated")]); + + internal static List StepsForComparison = [TrackingNames.Bindings, TrackingNames.BindingsWithDiagnostics]; + + internal static CSharpGeneratorDriver CreateDriver() + { + var generator = new BindingSourceGenerator(); + var sourceGenerator = generator.AsSourceGenerator(); + return CSharpGeneratorDriver.Create( + [sourceGenerator], + driverOptions: new GeneratorDriverOptions(disabledOutputs: IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true), + parseOptions: ParseOptions); + } + + internal static CodeGeneratorResult Run(string source) + { + var inputCompilation = CreateCompilation(source); + var driver = CreateDriver(); + + var result = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out Compilation compilation, out _).GetRunResult().Results.Single(); + + var generatedCodeDiagnostic = compilation.GetDiagnostics(); + var generatedCode = result.GeneratedSources.Length == 1 ? result.GeneratedSources.Single().SourceText.ToString() : ""; + + var trackedSteps = result.TrackedSteps; + + var resultBinding = trackedSteps.TryGetValue("Bindings", out ImmutableArray value) + ? (SetBindingInvocationDescription)value[0].Outputs[0].Value + : null; + + return new CodeGeneratorResult( + GeneratedCode: generatedCode, + SourceCompilationDiagnostics: inputCompilation.GetDiagnostics(), + SourceGeneratorDiagnostics: result.Diagnostics, + GeneratedCodeCompilationDiagnostics: generatedCodeDiagnostic, + Binding: resultBinding); + } + + private static Compilation CreateCompilationFromSyntaxTrees(List syntaxTrees) + => CSharpCompilation.Create("compilation", + syntaxTrees, + [ + MetadataReference.CreateFromFile(typeof(Microsoft.Maui.Controls.BindableObject).GetTypeInfo().Assembly.Location), + MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location), + MetadataReference.CreateFromFile(AssemblyLoadContext.Default.LoadFromAssemblyName(new AssemblyName("System.Runtime")).Location), + ], + new CSharpCompilationOptions(OutputKind.ConsoleApplication) + .WithNullableContextOptions(NullableContextOptions.Enable)); + + + internal static Compilation CreateCompilation(string source) + { + return CreateCompilationFromSyntaxTrees([CSharpSyntaxTree.ParseText(source, ParseOptions, path: @"Path\To\Program.cs")]); + } + + internal static Compilation CreateCompilation(List sources) + { + var syntaxTrees = sources.Select(source => CSharpSyntaxTree.ParseText(source, ParseOptions, path: $@"Path\To\Program{sources.IndexOf(source)}.cs")).ToList(); + return CreateCompilationFromSyntaxTrees(syntaxTrees); + } +} diff --git a/src/Core/tests/Benchmarks/Benchmarks/SourceGeneratedBindingBenchmarker.cs b/src/Core/tests/Benchmarks/Benchmarks/SourceGeneratedBindingBenchmarker.cs new file mode 100644 index 000000000000..b2de536b155f --- /dev/null +++ b/src/Core/tests/Benchmarks/Benchmarks/SourceGeneratedBindingBenchmarker.cs @@ -0,0 +1,71 @@ +#nullable enable +using System; +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Internals; + +namespace Microsoft.Maui.Benchmarks +{ + [MemoryDiagnoser] + public class SourceGeneratedBindingBenchmarker + { + // Avoids the warning: + // The minimum observed iteration time is 10.1000 us which is very small. It's recommended to increase it to at least 100.0000 ms using more operations. + const int Iterations = 10; + + public class MyObject : BindableObject + { + public static readonly BindableProperty NameProperty = BindableProperty.Create(nameof(Name), typeof(string), typeof(MyObject)); + + public string Name + { + get { return (string)GetValue(NameProperty); } + set { SetValue(NameProperty, value); } + } + + public MyObject? Child { get; set; } + + public List Children { get; private set; } = new List(); + } + + readonly MyObject Source = new() + { + Name = "A", + Child = new() { Name = "A.Child" }, + Children = + { + new() { Name = "A.Children[0]" }, + new() { Name = "A.Children[1]" }, + } + }; + readonly MyObject Target = new() { Name = "B" }; + + [Benchmark] + public void SourceGeneratedBindName() + { + for (int i = 0; i < Iterations; i++) + { + Target.SetBinding(MyObject.NameProperty, static (MyObject o) => o.Name, source: Source, mode: BindingMode.OneWay); + } + } + + [Benchmark] + public void SourceGeneratedBindChild() + { + for (int i = 0; i < Iterations; i++) + { + Target.SetBinding(MyObject.NameProperty, static (MyObject o) => o.Child?.Name, source: Source, mode: BindingMode.OneWay); + } + } + + [Benchmark] + public void SourceGeneratedBindChildIndexer() + { + for (int i = 0; i < Iterations; i++) + { + Target.SetBinding(MyObject.NameProperty, static (MyObject o) => o.Children[0].Name, source: Source, mode: BindingMode.OneWay); + } + } + } +} diff --git a/src/Core/tests/Benchmarks/Core.Benchmarks.csproj b/src/Core/tests/Benchmarks/Core.Benchmarks.csproj index 406cee0b2477..25abd7883830 100644 --- a/src/Core/tests/Benchmarks/Core.Benchmarks.csproj +++ b/src/Core/tests/Benchmarks/Core.Benchmarks.csproj @@ -3,6 +3,8 @@ Exe $(_MauiDotNetTfm) + true + $(InterceptorsPreviewNamespaces);Microsoft.Maui.Controls.Generated @@ -17,4 +19,11 @@ + + + +