diff --git a/Directory.Build.props b/Directory.Build.props index 638c793dd0..3134e90cb5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -60,6 +60,7 @@ https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitati CS1712: Type parameter has no matching typeparam tag in the XML comment CS1723: XML comment has cref attribute that refers to a type parameter CS1734: XML comment has a paramref tag, but there is no parameter by that name + MCT003: DefaultValueCreatorMethodName methods must return a new instance, not a static field or property, to ensure each BindableObject receives a unique instance. MVVMTK0001: Cannot apply [INotifyPropertyChanged] to a type that already declares the INotifyPropertyChanged interface MVVMTK0002: Cannot apply [ObservableObject] to a type that already declares the INotifyPropertyChanged interface MVVMTK0003: Cannot apply [ObservableObject] to a type that already declares the INotifyPropertyChanging interface. @@ -191,6 +192,7 @@ https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitati nullable, CS0419,CS1570,CS1571,CS1572,CS1573,CS1574,CS1580,CS1581,CS1584,CS1587,CS1589,CS1590,CS1591,CS1592,CS1598,CS1658,CS1710,CS1711,CS1712,CS1723,CS1734, CsWinRT1028,CsWinRT1030, + MCT003, MVVMTK0001,MVVMTK0002,MVVMTK0003,MVVMTK0009,MVVMTK0010,MVVMTK0011,MVVMTK0012,MVVMTK0013,MVVMTK0017,MVVMTK0018,MVVMTK0019,MVVMTK0020,MVVMTK0023,MVVMTK0024,MVVMTK0032,MVVMTK0033,MVVMTK0034,MVVMTK0035,MVVMTK0036,MVVMTK0039,MVVMTK0042,MVVMTK0049,MVVMTK0050,MVVMTK0052,MVVMTK0053,MVVMTK0054,MVVMTK0055,MVVMTK0056, NU1900,NU1901,NU1902,NU1903,NU1904,NU1905, xUnit1000,xUnit1001,xUnit1002,xUnit1003,xUnit1004,xUnit1005,xUnit1006,xUnit1007,xUnit1008,xUnit1009,xUnit1010,xUnit1011,xUnit1012,xUnit1013,xUnit1014,xUnit1015,xUnit1016,xUnit1017,xUnit1018,xUnit1019,xUnit1020,xUnit1021,xUnit1022,xUnit1023,xUnit1024,xUnit1025,xUnit1026,xUnit1027,xUnit1028,xUnit1029,xUnit1030,xUnit1031,xUnit1032,xUnit1033,xUnit1034,xUnit1035,xUnit1036,xUnit1037,xUnit1038,xUnit1039,xUnit1040,xUnit1041,xUnit1042,xUnit1043,xUnit1048,xUnit1049,xUnit1050,xUnit1051, diff --git a/src/CommunityToolkit.Maui.Analyzers.Benchmarks/BindablePropertyDefaultValueCreatorAnalyzerBenchmarks.cs b/src/CommunityToolkit.Maui.Analyzers.Benchmarks/BindablePropertyDefaultValueCreatorAnalyzerBenchmarks.cs new file mode 100644 index 0000000000..363dea1b2a --- /dev/null +++ b/src/CommunityToolkit.Maui.Analyzers.Benchmarks/BindablePropertyDefaultValueCreatorAnalyzerBenchmarks.cs @@ -0,0 +1,46 @@ +using BenchmarkDotNet.Attributes; +using CommunityToolkit.Maui.Analyzers.UnitTests; +namespace CommunityToolkit.Maui.Analyzers.Benchmarks; + +[MemoryDiagnoser] +public class BindablePropertyDefaultValueCreatorAnalyzerBenchmarks +{ + static readonly BindablePropertyDefaultValueCreatorAnalyzerTests bindablePropertyDefaultValueCreatorAnalyzerTests = new(); + static readonly AttachedBindablePropertyDefaultValueCreatorAnalyzerTests attachedBindablePropertyDefaultValueCreatorAnalyzerTests = new(); + + [Benchmark] + public Task BindablePropertyDefaultValueCreatorAnalyzerTests_VerifyErrorWhenDefaultValueCreatorMethodHasBlockBodyReturningStaticReadonlyField() + { + return bindablePropertyDefaultValueCreatorAnalyzerTests.VerifyErrorWhenDefaultValueCreatorMethodHasBlockBodyReturningStaticReadonlyField(); + } + + [Benchmark] + public Task AttachedBindablePropertyDefaultValueCreatorAnalyzerTests_VerifyErrorWhenDefaultValueCreatorMethodHasBlockBodyReturningStaticReadonlyField() + { + return attachedBindablePropertyDefaultValueCreatorAnalyzerTests.VerifyErrorWhenDefaultValueCreatorMethodHasBlockBodyReturningStaticReadonlyField(); + } + + [Benchmark] + public Task BindablePropertyDefaultValueCreatorAnalyzerTests_VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticReadonlyPropertyFromDifferentClass() + { + return bindablePropertyDefaultValueCreatorAnalyzerTests.VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticReadonlyPropertyFromDifferentClass(); + } + + [Benchmark] + public Task AttachedBindablePropertyDefaultValueCreatorAnalyzerTests_VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticReadonlyPropertyFromDifferentClass() + { + return attachedBindablePropertyDefaultValueCreatorAnalyzerTests.VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticReadonlyPropertyFromDifferentClass(); + } + + [Benchmark] + public Task BindablePropertyDefaultValueCreatorAnalyzerTests_VerifyNoErrorWhenDefaultValueCreatorMethodHasBlockBodyReturningNewInstance() + { + return bindablePropertyDefaultValueCreatorAnalyzerTests.VerifyNoErrorWhenDefaultValueCreatorMethodHasBlockBodyReturningNewInstance(); + } + + [Benchmark] + public Task AttachedBindablePropertyDefaultValueCreatorAnalyzerTests_VerifyNoErrorWhenDefaultValueCreatorMethodHasBlockBodyReturningNewInstance() + { + return attachedBindablePropertyDefaultValueCreatorAnalyzerTests.VerifyNoErrorWhenDefaultValueCreatorMethodHasBlockBodyReturningNewInstance(); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Analyzers.Benchmarks/Program.cs b/src/CommunityToolkit.Maui.Analyzers.Benchmarks/Program.cs index bb49aaade9..79a0377fd8 100644 --- a/src/CommunityToolkit.Maui.Analyzers.Benchmarks/Program.cs +++ b/src/CommunityToolkit.Maui.Analyzers.Benchmarks/Program.cs @@ -11,5 +11,6 @@ public static void Main(string[] args) BenchmarkRunner.Run(config, args); BenchmarkRunner.Run(config, args); BenchmarkRunner.Run(config, args); + BenchmarkRunner.Run(config, args); } } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Analyzers.UnitTests/AttachedBindablePropertyDefaultValueCreatorAnalyzerTests.cs b/src/CommunityToolkit.Maui.Analyzers.UnitTests/AttachedBindablePropertyDefaultValueCreatorAnalyzerTests.cs new file mode 100644 index 0000000000..39b332416b --- /dev/null +++ b/src/CommunityToolkit.Maui.Analyzers.UnitTests/AttachedBindablePropertyDefaultValueCreatorAnalyzerTests.cs @@ -0,0 +1,695 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using static CommunityToolkit.Maui.Analyzers.UnitTests.CSharpAnalyzerVerifier; + +namespace CommunityToolkit.Maui.Analyzers.UnitTests; + +public class AttachedBindablePropertyDefaultValueCreatorAnalyzerTests +{ + [Fact] + public void AttachedBindablePropertyDefaultValueCreatorAnalyzerId() + { + Assert.Equal("MCT003", BindablePropertyDefaultValueCreatorAnalyzer.DiagnosticId); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsNewInstance() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public static partial class TestContainer + { + static IList CreateDefaultStateViews(BindableObject bindable) => []; + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsNewList() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public static partial class TestContainer + { + static IList CreateDefaultStateViews(BindableObject bindable) => new List(); + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsMethodCall() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public static partial class TestContainer + { + static IList CreateDefaultStateViews(BindableObject bindable) => CreateNewList(); + + static IList CreateNewList() => new List(); + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsNonStaticProperty() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty("Text", DefaultValueCreatorMethodName = nameof(CreateDefaultText))] + public static partial class TestContainer + { + static string CreateDefaultText(BindableObject bindable) => ((TestClass)bindable).DefaultText; + } + + public class TestClass : BindableObject + { + public string DefaultText { get; } = "Default"; + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodHasBlockBodyReturningNewInstance() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public static partial class TestContainer + { + static IList CreateDefaultStateViews(BindableObject bindable) + { + return []; + } + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenNoDefaultValueCreatorMethodName() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty("Text")] + public static partial class TestContainer + { + static readonly string DefaultText = "Default"; + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticReadonlyField() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public static partial class TestContainer + { + static readonly IList DefaultStateViews = new List(); + + static IList CreateDefaultStateViews(BindableObject bindable) => DefaultStateViews; + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(14, 3, 14, 92) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultStateViews")); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticReadonlyProperty() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public static partial class TestContainer + { + static IList DefaultStateViews { get; } = new List(); + + static IList CreateDefaultStateViews(BindableObject bindable) => DefaultStateViews; + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(14, 3, 14, 92) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultStateViews")); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodReturnsCreateDefaultValueDelegateThatReturnsAStaticInstance() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateStateViewsDelegate))] + public static partial class TestContainer + { + static readonly BindableProperty.CreateDefaultValueDelegate CreateStateViewsDelegate = _ => StateViewList; + + static List StateViewList { get; } = []; + } + } + """; + + await VerifyAnalyzerAsync(source, + Diagnostic() + .WithSpan(12, 3, 12, 109) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateStateViewsDelegate")); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticReadonlyPropertyFromDifferentClass() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public static partial class TestContainer + { + static IList CreateDefaultStateViews(BindableObject bindable) => DefaultValues.StateViews; + } + + public static class DefaultValues + { + public static IList StateViews { get; } = new List(); + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(12, 3, 12, 99) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultStateViews")); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodHasBlockBodyReturningStaticReadonlyField() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public static partial class TestContainer + { + static readonly IList DefaultStateViews = new List(); + + static IList CreateDefaultStateViews(BindableObject bindable) + { + return DefaultStateViews; + } + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(14, 3, 17, 4) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultStateViews")); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodHasMultipleReturnStatementsWithOneReturningStaticReadonly() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public static partial class TestContainer + { + static readonly IList DefaultStateViews = new List(); + + static IList CreateDefaultStateViews(BindableObject bindable) + { + if (bindable is null) + { + return DefaultStateViews; + } + return new List(); + } + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(14, 3, 21, 4) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultStateViews")); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticReadonlyFieldForStringType() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty("Text", DefaultValueCreatorMethodName = nameof(CreateDefaultText))] + public static partial class TestContainer + { + static readonly string DefaultText = "Default"; + + static string CreateDefaultText(BindableObject bindable) => DefaultText; + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(13, 3, 13, 75) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultText")); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticNonReadonlyProperty() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public static partial class TestContainer + { + static IList DefaultStateViews { get; set; } = new List(); + + static IList CreateDefaultStateViews(BindableObject bindable) => DefaultStateViews; + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(14, 3, 14, 92) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultStateViews")); + } + + [Fact] + public async Task VerifyNoErrorWhenAttributeIsNotAttachedBindableProperty() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System; + using System.Collections.Generic; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [Obsolete("Test")] + public static partial class TestContainer + { + static readonly IList DefaultStateViews = new List(); + + static IList CreateDefaultStateViews(BindableObject bindable) => DefaultStateViews; + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsNonStaticReadonlyField() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public static partial class TestContainer + { + static IList CreateDefaultStateViews(BindableObject bindable) + { + var instance = new InstanceClass(); + return instance.DefaultStateViews; + } + } + + public class InstanceClass + { + public readonly IList DefaultStateViews = new List(); + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticReadonlyFieldWithConditionalExpression() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public static partial class TestContainer + { + static readonly IList DefaultStateViews = new List(); + + static IList CreateDefaultStateViews(BindableObject bindable) => bindable is not null ? DefaultStateViews : []; + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(14, 3, 14, 120) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultStateViews")); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsLiteral() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty("Text", DefaultValueCreatorMethodName = nameof(CreateDefaultText))] + public static partial class TestContainer + { + static string CreateDefaultText(BindableObject bindable) => "Default"; + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsDefault() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews), IsNullable = true)] + public static partial class TestContainer + { + static IList? CreateDefaultStateViews(BindableObject bindable) => default; + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsNull() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews), IsNullable = true)] + public static partial class TestContainer + { + static IList? CreateDefaultStateViews(BindableObject bindable) => null; + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsCreateDefaultValueDelegateThatReturnsANewInstance() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty>("StateViews", DefaultValueCreatorMethodName = nameof(CreateStateViewsDelegate))] + public static partial class TestContainer + { + static readonly BindableProperty.CreateDefaultValueDelegate CreateStateViewsDelegate = (x) => new List(); + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsCreateMethodReturningConst() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [AttachedBindableProperty("StateViews", DefaultValueCreatorMethodName = nameof(CreateStateViewsDelegate))] + public static partial class TestContainer + { + const bool trueConstant = true; + static bool CreateStateViewsDelegate() => trueConstant; + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + static Task VerifyAnalyzerAsync(string source, params IReadOnlyList expected) + { + return CSharpAnalyzerVerifier + .VerifyAnalyzerAsync( + source, + [ + typeof(Options), // CommunityToolkit.Maui + typeof(Core.Options), // CommunityToolkit.Maui.Core + ], + expected); + } +} diff --git a/src/CommunityToolkit.Maui.Analyzers.UnitTests/BindablePropertyDefaultValueCreatorAnalyzerTests.cs b/src/CommunityToolkit.Maui.Analyzers.UnitTests/BindablePropertyDefaultValueCreatorAnalyzerTests.cs new file mode 100644 index 0000000000..b958f379bd --- /dev/null +++ b/src/CommunityToolkit.Maui.Analyzers.UnitTests/BindablePropertyDefaultValueCreatorAnalyzerTests.cs @@ -0,0 +1,842 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using static CommunityToolkit.Maui.Analyzers.UnitTests.CSharpAnalyzerVerifier; + +namespace CommunityToolkit.Maui.Analyzers.UnitTests; + +public class BindablePropertyDefaultValueCreatorAnalyzerTests +{ + [Fact] + public void BindablePropertyDefaultValueCreatorAnalyzerId() + { + Assert.Equal("MCT003", BindablePropertyDefaultValueCreatorAnalyzer.DiagnosticId); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsNewInstance() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public partial IList StateViews { get; set; } + + static IList CreateDefaultStateViews(BindableObject bindable) => []; + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsNewList() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public partial IList StateViews { get; set; } + + static IList CreateDefaultStateViews(BindableObject bindable) => new List(); + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsMethodCall() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public partial IList StateViews { get; set; } + + static IList CreateDefaultStateViews(BindableObject bindable) => CreateNewList(); + + static IList CreateNewList() => new List(); + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsNonStaticProperty() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultText))] + public partial string Text { get; set; } + + static string CreateDefaultText(BindableObject bindable) => ((TestControl)bindable).DefaultText; + + public string DefaultText { get; } = "Default"; + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? TextProperty; + public partial string Text { get => false ? field : (string)GetValue(TextProperty); set => SetValue(TextProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodHasBlockBodyReturningNewInstance() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public partial IList StateViews { get; set; } + + static IList CreateDefaultStateViews(BindableObject bindable) + { + return []; + } + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticReadonlyField() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public partial IList StateViews { get; set; } + + static readonly IList DefaultStateViews = new List(); + + static IList CreateDefaultStateViews(BindableObject bindable) => DefaultStateViews; + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(17, 3, 17, 92) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultStateViews")); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticReadonlyProperty() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public partial IList StateViews { get; set; } + + static IList DefaultStateViews { get; } = new List(); + + static IList CreateDefaultStateViews(BindableObject bindable) => DefaultStateViews; + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(17, 3, 17, 92) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultStateViews")); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticReadonlyPropertyFromDifferentClass() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public partial IList StateViews { get; set; } + + static IList CreateDefaultStateViews(BindableObject bindable) => DefaultValues.StateViews; + } + + public static class DefaultValues + { + public static IList StateViews { get; } = new List(); + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(15, 3, 15, 99) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultStateViews")); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodHasBlockBodyReturningStaticReadonlyField() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public partial IList StateViews { get; set; } + + static readonly IList DefaultStateViews = new List(); + + static IList CreateDefaultStateViews(BindableObject bindable) + { + return DefaultStateViews; + } + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(16, 3, 19, 4) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultStateViews")); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodHasMultipleReturnStatementsWithOneReturningStaticReadonly() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public partial IList StateViews { get; set; } + + static readonly IList DefaultStateViews = new List(); + + static IList CreateDefaultStateViews(BindableObject bindable) + { + if (bindable is null) + { + return DefaultStateViews; + } + return new List(); + } + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(17, 3, 24, 4) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultStateViews")); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticReadonlyFieldForStringType() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultText))] + public partial string Text { get; set; } + + static readonly string DefaultText = "Default"; + + static string CreateDefaultText(BindableObject bindable) => DefaultText; + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? TextProperty; + public partial string Text { get => false ? field : (string)GetValue(TextProperty); set => SetValue(TextProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(16, 3, 16, 75) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultText")); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticNonReadonlyProperty() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public partial IList StateViews { get; set; } + + static IList DefaultStateViews { get; set; } = new List(); + + static IList CreateDefaultStateViews(BindableObject bindable) => DefaultStateViews; + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(17, 3, 17, 92) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultStateViews")); + } + + [Fact] + public async Task VerifyNoErrorWhenAttributeIsNotBindableProperty() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using System; + using System.Collections.Generic; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + [Obsolete("Test")] + public partial class TestControl : View + { + static readonly IList DefaultStateViews = new List(); + + static IList CreateDefaultStateViews(BindableObject bindable) => DefaultStateViews; + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsNonStaticReadonlyField() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public partial IList StateViews { get; set; } + + static IList CreateDefaultStateViews(BindableObject bindable) + { + var instance = new InstanceClass(); + return instance.DefaultStateViews; + } + } + + public class InstanceClass + { + public readonly IList DefaultStateViews = new List(); + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodReturnsStaticReadonlyFieldWithConditionalExpression() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public partial IList StateViews { get; set; } + + static readonly IList DefaultStateViews = new List(); + + static IList CreateDefaultStateViews(BindableObject bindable) => bindable is not null ? DefaultStateViews : []; + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(17, 3, 17, 120) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateDefaultStateViews")); + } + + [Fact] + public async Task VerifyErrorWhenDefaultValueCreatorMethodReturnsCreateDefaultValueDelegateThatReturnsAStaticMember() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateStateViewsDelegate))] + public partial IList? StateViews { get; set; } + + static readonly BindableProperty.CreateDefaultValueDelegate CreateStateViewsDelegate = _ => StateViewsList; + + static List StateViewsList { get; } = []; + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync( + source, + Diagnostic() + .WithSpan(14, 3, 14, 110) + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments("CreateStateViewsDelegate")); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsLiteral() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultText))] + public partial string Text { get; set; } + + static string CreateDefaultText(BindableObject bindable) => "Default"; + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? TextProperty; + public partial string Text { get => false ? field : (string)GetValue(TextProperty); set => SetValue(TextProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsDefault() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public partial IList? StateViews { get; set; } + + static IList? CreateDefaultStateViews(BindableObject bindable) => default; + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsNull() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + #pragma warning disable CS9248 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateDefaultStateViews))] + public partial IList? StateViews { get; set; } + + static IList? CreateDefaultStateViews(BindableObject bindable) => null; + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsCreateDefaultValueDelegateThatReturnsANewInstance() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateStateViewsDelegate))] + public partial IList? StateViews { get; set; } + + static readonly BindableProperty.CreateDefaultValueDelegate CreateStateViewsDelegate = (x) => new List(); + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial IList StateViews { get => false ? field : (IList)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task VerifyNoErrorWhenDefaultValueCreatorMethodReturnsAConst() + { + const string source = + /* language=C#-test */ + //lang=csharp + """ + #nullable enable + #pragma warning disable MCTEXP001 + using System.Collections.Generic; + using CommunityToolkit.Maui; + using Microsoft.Maui.Controls; + + namespace CommunityToolkit.Maui.UnitTests + { + public partial class TestControl : View + { + [BindableProperty(DefaultValueCreatorMethodName = nameof(CreateStateViewsDelegate))] + public partial bool StateViews { get; set; } + + const bool trueConstant = true; + static bool CreateStateViewsDelegate() => trueConstant; + } + + public partial class TestControl + { + public static readonly global::Microsoft.Maui.Controls.BindableProperty? StateViewsProperty; + public partial bool StateViews { get => false ? field : (bool)GetValue(StateViewsProperty); set => SetValue(StateViewsProperty, value); } + } + } + """; + + await VerifyAnalyzerAsync(source); + } + + static Task VerifyAnalyzerAsync(string source, params IReadOnlyList expected) + { + return CSharpAnalyzerVerifier + .VerifyAnalyzerAsync( + source, + [ + typeof(Options), // CommunityToolkit.Maui + typeof(Core.Options), // CommunityToolkit.Maui.Core + ], + expected); + } +} diff --git a/src/CommunityToolkit.Maui.Analyzers/AnalyzerReleases.Shipped.md b/src/CommunityToolkit.Maui.Analyzers/AnalyzerReleases.Shipped.md index 4be37bea94..c153d8fbee 100644 --- a/src/CommunityToolkit.Maui.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/CommunityToolkit.Maui.Analyzers/AnalyzerReleases.Shipped.md @@ -12,4 +12,12 @@ MCT001 | Usage | Error | `.UseMauiCommunityToolkit()` Not Found on MauiAp Rule ID | Category | Severity | Notes --------|----------|----------|----------------------------------------------------- -MCT002 | Usage | Error | The value of MaximumRating must be between 1 and 10 \ No newline at end of file +MCT002 | Usage | Error | The value of MaximumRating must be between 1 and 10 + +## Release 14.0.1 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|----------------------------------------------------- +MCT003 | Usage | Warning | To ensure each BindableObject receives a unique instance, DefaultValueCreatorMethodName methods must return a new instance, not a static field or property. \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Analyzers/BindablePropertyDefaultValueCreatorAnalyzer.cs b/src/CommunityToolkit.Maui.Analyzers/BindablePropertyDefaultValueCreatorAnalyzer.cs new file mode 100644 index 0000000000..75eb06a8ec --- /dev/null +++ b/src/CommunityToolkit.Maui.Analyzers/BindablePropertyDefaultValueCreatorAnalyzer.cs @@ -0,0 +1,329 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CommunityToolkit.Maui.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class BindablePropertyDefaultValueCreatorAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "MCT003"; + const string category = "Usage"; + const string bindablePropertyAttributeName = "BindablePropertyAttribute"; + const string attachedBindablePropertyAttributeName = "AttachedBindablePropertyAttribute"; + const string defaultValueCreatorMethodNameProperty = "DefaultValueCreatorMethodName"; + + static readonly LocalizableString title = new LocalizableResourceString(nameof(Resources.BindablePropertyDefaultValueCreatorErrorTitle), Resources.ResourceManager, typeof(Resources)); + static readonly LocalizableString messageFormat = new LocalizableResourceString(nameof(Resources.BindablePropertyDefaultValueCreatorMessageFormat), Resources.ResourceManager, typeof(Resources)); + static readonly LocalizableString description = new LocalizableResourceString(nameof(Resources.BindablePropertyDefaultValueCreatorErrorMessage), Resources.ResourceManager, typeof(Resources)); + static readonly DiagnosticDescriptor rule = new(DiagnosticId, title, messageFormat, category, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: description); + + public override ImmutableArray SupportedDiagnostics { get; } = [rule]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeAttribute, SyntaxKind.Attribute); + } + + static void AnalyzeAttribute(SyntaxNodeAnalysisContext context) + { + if (context.Node is not AttributeSyntax attributeSyntax) + { + return; + } + + var attributeSymbol = context.SemanticModel.GetSymbolInfo(attributeSyntax, context.CancellationToken).Symbol?.ContainingType; + + if (attributeSymbol is null) + { + return; + } + + if (!IsBindablePropertyAttribute(attributeSymbol)) + { + return; + } + + var defaultValueCreatorMethodName = GetDefaultValueCreatorMethodName(attributeSyntax); + if (defaultValueCreatorMethodName is null || string.IsNullOrWhiteSpace(defaultValueCreatorMethodName)) + { + return; + } + + var containingType = attributeSyntax.Ancestors().OfType().FirstOrDefault(); + + if (containingType is null) + { + return; + } + + var methodDeclaration = FindMethodInType(containingType, defaultValueCreatorMethodName); + + if (methodDeclaration is not null) + { + if (DoesReturnStaticReadOnlyFieldOrProperty(methodDeclaration, context.SemanticModel, context.CancellationToken)) + { + var diagnostic = Diagnostic.Create(rule, methodDeclaration.GetLocation(), defaultValueCreatorMethodName); + context.ReportDiagnostic(diagnostic); + } + return; + } + + var fieldOrPropertyDeclaration = FindFieldOrPropertyInType(containingType, defaultValueCreatorMethodName); + + if (fieldOrPropertyDeclaration is not null) + { + if (DoesDelegateInitializerReturnStaticReadOnlyMember(fieldOrPropertyDeclaration, context.SemanticModel, context.CancellationToken)) + { + var diagnostic = Diagnostic.Create(rule, fieldOrPropertyDeclaration.GetLocation(), defaultValueCreatorMethodName); + context.ReportDiagnostic(diagnostic); + } + } + } + + static bool IsBindablePropertyAttribute(INamedTypeSymbol attributeSymbol) + { + var currentType = attributeSymbol; + + while (currentType is not null) + { + if (currentType.Name.StartsWith(attachedBindablePropertyAttributeName, StringComparison.Ordinal) + || currentType.Name.StartsWith(bindablePropertyAttributeName, StringComparison.Ordinal)) + { + return true; + } + + currentType = currentType.BaseType; + } + + return false; + } + + static string? GetDefaultValueCreatorMethodName(AttributeSyntax attributeSyntax) + { + if (attributeSyntax.ArgumentList is null) + { + return null; + } + + foreach (var argument in attributeSyntax.ArgumentList.Arguments) + { + if (argument.NameEquals?.Name.Identifier.ValueText != defaultValueCreatorMethodNameProperty) + { + continue; + } + + // Handle string literal: DefaultValueCreatorMethodName = "MethodName" + if (argument.Expression is LiteralExpressionSyntax literalExpression + && literalExpression.IsKind(SyntaxKind.StringLiteralExpression)) + { + return literalExpression.Token.ValueText; + } + + // Handle nameof expression: DefaultValueCreatorMethodName = nameof(MethodName) + if (argument.Expression is InvocationExpressionSyntax invocationExpression + && invocationExpression.Expression is IdentifierNameSyntax { Identifier.ValueText: "nameof" } + && invocationExpression.ArgumentList.Arguments.Count == 1) + { + var nameofArgument = invocationExpression.ArgumentList.Arguments[0]; + if (nameofArgument.Expression is IdentifierNameSyntax identifierName) + { + return identifierName.Identifier.ValueText; + } + } + } + + return null; + } + + static MethodDeclarationSyntax? FindMethodInType(TypeDeclarationSyntax typeDeclaration, string methodName) + { + return typeDeclaration + .DescendantNodes() + .OfType() + .FirstOrDefault(method => method.Identifier.ValueText == methodName); + } + + static SyntaxNode? FindFieldOrPropertyInType(TypeDeclarationSyntax typeDeclaration, string memberName) + { + var fieldDeclaration = typeDeclaration + .DescendantNodes() + .OfType() + .FirstOrDefault(field => field.Declaration.Variables.Any(v => v.Identifier.ValueText == memberName)); + + if (fieldDeclaration is not null) + { + return fieldDeclaration; + } + + var propertyDeclaration = typeDeclaration + .DescendantNodes() + .OfType() + .FirstOrDefault(prop => prop.Identifier.ValueText == memberName); + + return propertyDeclaration; + } + + static bool DoesReturnStaticReadOnlyFieldOrProperty(MethodDeclarationSyntax methodDeclaration, SemanticModel semanticModel, CancellationToken cancellationToken) + { + var returnStatements = methodDeclaration + .DescendantNodes() + .OfType(); + + foreach (var returnStatement in returnStatements) + { + if (returnStatement.Expression is null) + { + continue; + } + + if (IsStaticFieldOrProperty(returnStatement.Expression, semanticModel, cancellationToken)) + { + return true; + } + } + + if (methodDeclaration.ExpressionBody is not null) + { + return IsStaticFieldOrProperty(methodDeclaration.ExpressionBody.Expression, semanticModel, cancellationToken); + } + + return false; + } + + static bool DoesDelegateInitializerReturnStaticReadOnlyMember(SyntaxNode memberDeclaration, SemanticModel semanticModel, CancellationToken cancellationToken) + { + if (memberDeclaration is FieldDeclarationSyntax fieldDeclaration) + { + foreach (var variable in fieldDeclaration.Declaration.Variables) + { + if (variable.Initializer?.Value is null) + { + continue; + } + + if (DoesLambdaOrDelegateInitializerReturnStaticMember(variable.Initializer.Value, semanticModel, cancellationToken)) + { + return true; + } + } + } + else if (memberDeclaration is PropertyDeclarationSyntax propertyDeclaration) + { + if (propertyDeclaration.Initializer?.Value is not null) + { + return DoesLambdaOrDelegateInitializerReturnStaticMember(propertyDeclaration.Initializer.Value, semanticModel, cancellationToken); + } + } + + return false; + } + + static bool DoesLambdaOrDelegateInitializerReturnStaticMember(ExpressionSyntax expression, SemanticModel semanticModel, CancellationToken cancellationToken) + { + if (expression is ParenthesizedLambdaExpressionSyntax parenthesizedLambda) + { + if (parenthesizedLambda.ExpressionBody is not null) + { + return IsStaticFieldOrProperty(parenthesizedLambda.ExpressionBody, semanticModel, cancellationToken); + } + + if (parenthesizedLambda.Block is not null) + { + var returnStatements = parenthesizedLambda.Block.DescendantNodes().OfType(); + foreach (var returnStatement in returnStatements) + { + if (returnStatement.Expression is not null && IsStaticFieldOrProperty(returnStatement.Expression, semanticModel, cancellationToken)) + { + return true; + } + } + } + } + else if (expression is SimpleLambdaExpressionSyntax simpleLambda) + { + if (simpleLambda.ExpressionBody is not null) + { + return IsStaticFieldOrProperty(simpleLambda.ExpressionBody, semanticModel, cancellationToken); + } + + if (simpleLambda.Block is not null) + { + var returnStatements = simpleLambda.Block.DescendantNodes().OfType(); + foreach (var returnStatement in returnStatements) + { + if (returnStatement.Expression is not null && IsStaticFieldOrProperty(returnStatement.Expression, semanticModel, cancellationToken)) + { + return true; + } + } + } + } + + return false; + } + + static bool IsStaticFieldOrProperty(ExpressionSyntax expression, SemanticModel semanticModel, CancellationToken cancellationToken) + { + // Handle conditional expressions (ternary operator) + // e.g., condition ? DefaultStateViews : [] + if (expression is ConditionalExpressionSyntax conditionalExpression) + { + // Check if either branch returns a static readonly member + return IsStaticFieldOrProperty(conditionalExpression.WhenTrue, semanticModel, cancellationToken) + || IsStaticFieldOrProperty(conditionalExpression.WhenFalse, semanticModel, cancellationToken); + } + + // Handle binary expressions (e.g., null coalescing operator ??) + // e.g., null ?? DefaultStateViews + if (expression is BinaryExpressionSyntax binaryExpression) + { + // Check if either side returns a static readonly member + return IsStaticFieldOrProperty(binaryExpression.Left, semanticModel, cancellationToken) + || IsStaticFieldOrProperty(binaryExpression.Right, semanticModel, cancellationToken); + } + + // Handle switch expressions + // e.g., bindable switch { null => DefaultStateViews, _ => new List() } + if (expression is SwitchExpressionSyntax switchExpression) + { + // Check if any arm returns a static readonly member + foreach (var arm in switchExpression.Arms) + { + if (IsStaticFieldOrProperty(arm.Expression, semanticModel, cancellationToken)) + { + return true; + } + } + return false; + } + + // Handle parenthesized expressions + // e.g., (DefaultStateViews) + if (expression is ParenthesizedExpressionSyntax parenthesizedExpression) + { + return IsStaticFieldOrProperty(parenthesizedExpression.Expression, semanticModel, cancellationToken); + } + + // Handle cast expressions + // e.g., (IList)DefaultStateViews + if (expression is CastExpressionSyntax castExpression) + { + return IsStaticFieldOrProperty(castExpression.Expression, semanticModel, cancellationToken); + } + + // Check if the expression itself is a static readonly member + var symbolInfo = semanticModel.GetSymbolInfo(expression, cancellationToken); + + return symbolInfo.Symbol switch + { + IFieldSymbol fieldSymbol when !fieldSymbol.IsConst => fieldSymbol.IsStatic, + IPropertySymbol propertySymbol => propertySymbol.IsStatic, + _ => false + }; + } +} diff --git a/src/CommunityToolkit.Maui.Analyzers/Resources.Designer.cs b/src/CommunityToolkit.Maui.Analyzers/Resources.Designer.cs index 8748988aaf..6def28c4f6 100644 --- a/src/CommunityToolkit.Maui.Analyzers/Resources.Designer.cs +++ b/src/CommunityToolkit.Maui.Analyzers/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace CommunityToolkit.Maui.Analyzers { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { @@ -60,6 +60,33 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to To ensure each BindableObject receives a unique instance, DefaultValueCreatorMethodName methods must return a new instance, not a static field or property.. + /// + internal static string BindablePropertyDefaultValueCreatorErrorMessage { + get { + return ResourceManager.GetString("BindablePropertyDefaultValueCreatorErrorMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to DefaultValueCreatorMethodName Returns Shared Static Instance. + /// + internal static string BindablePropertyDefaultValueCreatorErrorTitle { + get { + return ResourceManager.GetString("BindablePropertyDefaultValueCreatorErrorTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Method '{0}' returns a static field or property. This will cause all instances to share the same reference. Return a new instance instead.. + /// + internal static string BindablePropertyDefaultValueCreatorMessageFormat { + get { + return ResourceManager.GetString("BindablePropertyDefaultValueCreatorMessageFormat", resourceCulture); + } + } + /// /// Looks up a localized string similar to `.UseMauiCommunityToolkit()` must be chained to `.UseMauiApp<T>()`. /// @@ -70,7 +97,7 @@ internal static string InitalizationMessageFormat { } /// - /// Looks up a localized string similar to `.UseMauiCommunityToolkit()` is required to initalize .NET MAUI Community Toolkit. + /// Looks up a localized string similar to `.UseMauiCommunityToolkit()` is required to initialize .NET MAUI Community Toolkit. /// internal static string InitializationErrorMessage { get { diff --git a/src/CommunityToolkit.Maui.Analyzers/Resources.resx b/src/CommunityToolkit.Maui.Analyzers/Resources.resx index d16f443f3f..7d8a8382dc 100644 --- a/src/CommunityToolkit.Maui.Analyzers/Resources.resx +++ b/src/CommunityToolkit.Maui.Analyzers/Resources.resx @@ -1,5 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx @@ -21,4 +126,13 @@ `.UseMauiCommunityToolkit()` Not Found on MauiAppBuilder + + Method '{0}' returns a static field or property. This will cause all instances to share the same reference. Return a new instance instead. + + + To ensure each BindableObject receives a unique instance, DefaultValueCreatorMethodName methods must return a new instance, not a static field or property. + + + DefaultValueCreatorMethodName Returns Shared Static Instance + \ No newline at end of file diff --git a/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj b/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj index e97aa548ac..29fc3a6917 100644 --- a/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj +++ b/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj @@ -48,6 +48,7 @@ +