diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/Input/Models/CommandInfo.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/Input/Models/CommandInfo.cs index 70372a03f..e1ce546ac 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/Input/Models/CommandInfo.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/Input/Models/CommandInfo.cs @@ -11,7 +11,7 @@ namespace CommunityToolkit.Mvvm.SourceGenerators.Input.Models; /// A model with gathered info on a given command method. /// /// The name of the target method. -/// The resulting field name for the generated command. +/// The resulting field name for the generated command, or null if the is available. /// The resulting property name for the generated command. /// The command interface type name. /// The command class type name. @@ -26,7 +26,7 @@ namespace CommunityToolkit.Mvvm.SourceGenerators.Input.Models; /// The sequence of forwarded attributes for the generated members. internal sealed record CommandInfo( string MethodName, - string FieldName, + string? FieldName, string PropertyName, string CommandInterfaceType, string CommandClassType, diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/Input/RelayCommandGenerator.Execute.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/Input/RelayCommandGenerator.Execute.cs index 57eb1ebcf..7bbce3319 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/Input/RelayCommandGenerator.Execute.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/Input/RelayCommandGenerator.Execute.cs @@ -135,6 +135,17 @@ public static bool TryGetInfo( token.ThrowIfCancellationRequested(); + // Get the option to force a backing field, if any + if (!TryGetUseBackingField( + attributeData, + semanticModel, + out bool useBackingField)) + { + goto Failure; + } + + token.ThrowIfCancellationRequested(); + // Get all forwarded attributes (don't stop in case of errors, just ignore faulting attributes) GatherForwardedAttributes( methodSymbol, @@ -147,7 +158,7 @@ public static bool TryGetInfo( commandInfo = new CommandInfo( methodSymbol.Name, - fieldName, + useBackingField ? fieldName : null, propertyName, commandInterfaceType, commandClassType, @@ -207,25 +218,33 @@ public static ImmutableArray GetSyntax(CommandInfo comm .Select(static a => AttributeList(SingletonSeparatedList(a.GetSyntax()))) .ToArray(); - // Construct the generated field as follows: - // - // /// The backing field for - // [global::System.CodeDom.Compiler.GeneratedCode("...", "...")] - // - // private ? ; - FieldDeclarationSyntax fieldDeclaration = - FieldDeclaration( - VariableDeclaration(NullableType(IdentifierName(commandClassTypeName))) - .AddVariables(VariableDeclarator(Identifier(commandInfo.FieldName)))) - .AddModifiers(Token(SyntaxKind.PrivateKeyword)) - .AddAttributeLists( - AttributeList(SingletonSeparatedList( - Attribute(IdentifierName("global::System.CodeDom.Compiler.GeneratedCode")) - .AddArgumentListArguments( - AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(RelayCommandGenerator).FullName))), - AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(RelayCommandGenerator).Assembly.GetName().Version.ToString())))))) - .WithOpenBracketToken(Token(TriviaList(Comment($"/// The backing field for .")), SyntaxKind.OpenBracketToken, TriviaList()))) - .AddAttributeLists(forwardedFieldAttributes); + using ImmutableArrayBuilder declarations = ImmutableArrayBuilder.Rent(); + + // Declare a backing field if needed + if (commandInfo.FieldName is not null) + { + // Construct the generated field as follows: + // + // /// The backing field for + // [global::System.CodeDom.Compiler.GeneratedCode("...", "...")] + // + // private ? ; + FieldDeclarationSyntax fieldDeclaration = + FieldDeclaration( + VariableDeclaration(NullableType(IdentifierName(commandClassTypeName))) + .AddVariables(VariableDeclarator(Identifier(commandInfo.FieldName)))) + .AddModifiers(Token(SyntaxKind.PrivateKeyword)) + .AddAttributeLists( + AttributeList(SingletonSeparatedList( + Attribute(IdentifierName("global::System.CodeDom.Compiler.GeneratedCode")) + .AddArgumentListArguments( + AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(RelayCommandGenerator).FullName))), + AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(RelayCommandGenerator).Assembly.GetName().Version.ToString())))))) + .WithOpenBracketToken(Token(TriviaList(Comment($"/// The backing field for .")), SyntaxKind.OpenBracketToken, TriviaList()))) + .AddAttributeLists(forwardedFieldAttributes); + + declarations.Add(fieldDeclaration); + } // Prepares the argument to pass the underlying method to invoke using ImmutableArrayBuilder commandCreationArguments = ImmutableArrayBuilder.Rent(); @@ -337,35 +356,44 @@ public static ImmutableArray GetSyntax(CommandInfo comm ArrowExpressionClause( AssignmentExpression( SyntaxKind.CoalesceAssignmentExpression, - IdentifierName(commandInfo.FieldName), + commandInfo.FieldName is not null ? IdentifierName(commandInfo.FieldName) : IdentifierName("field"), ObjectCreationExpression(IdentifierName(commandClassTypeName)) .AddArgumentListArguments(commandCreationArguments.ToArray())))) .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)); + declarations.Add(propertyDeclaration); + // Conditionally declare the additional members for the cancel commands if (commandInfo.IncludeCancelCommand) { // Prepare all necessary member and type names - string cancelCommandFieldName = $"{commandInfo.FieldName.Substring(0, commandInfo.FieldName.Length - "Command".Length)}CancelCommand"; - string cancelCommandPropertyName = $"{commandInfo.PropertyName.Substring(0, commandInfo.PropertyName.Length - "Command".Length)}CancelCommand"; + string? cancelCommandFieldName = commandInfo.FieldName is not null ? $"{commandInfo.FieldName[..^"Command".Length]}CancelCommand" : null; + string cancelCommandPropertyName = $"{commandInfo.PropertyName[..^"Command".Length]}CancelCommand"; - // Construct the generated field for the cancel command as follows: - // - // /// The backing field for - // [global::System.CodeDom.Compiler.GeneratedCode("...", "...")] - // private global::System.Windows.Input.ICommand? ; - FieldDeclarationSyntax cancelCommandFieldDeclaration = - FieldDeclaration( - VariableDeclaration(NullableType(IdentifierName("global::System.Windows.Input.ICommand"))) - .AddVariables(VariableDeclarator(Identifier(cancelCommandFieldName)))) - .AddModifiers(Token(SyntaxKind.PrivateKeyword)) - .AddAttributeLists( - AttributeList(SingletonSeparatedList( - Attribute(IdentifierName("global::System.CodeDom.Compiler.GeneratedCode")) - .AddArgumentListArguments( - AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(RelayCommandGenerator).FullName))), - AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(RelayCommandGenerator).Assembly.GetName().Version.ToString())))))) - .WithOpenBracketToken(Token(TriviaList(Comment($"/// The backing field for .")), SyntaxKind.OpenBracketToken, TriviaList()))); + // Declare a backing field for the cancel command if needed. + // This is only needed if we can't use the field keyword for the main command, as otherwise the cancel command can just use its own field keyword. + if (cancelCommandFieldName is not null) + { + // Construct the generated field for the cancel command as follows: + // + // /// The backing field for + // [global::System.CodeDom.Compiler.GeneratedCode("...", "...")] + // private global::System.Windows.Input.ICommand? ; + FieldDeclarationSyntax cancelCommandFieldDeclaration = + FieldDeclaration( + VariableDeclaration(NullableType(IdentifierName("global::System.Windows.Input.ICommand"))) + .AddVariables(VariableDeclarator(Identifier(cancelCommandFieldName)))) + .AddModifiers(Token(SyntaxKind.PrivateKeyword)) + .AddAttributeLists( + AttributeList(SingletonSeparatedList( + Attribute(IdentifierName("global::System.CodeDom.Compiler.GeneratedCode")) + .AddArgumentListArguments( + AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(RelayCommandGenerator).FullName))), + AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(RelayCommandGenerator).Assembly.GetName().Version.ToString())))))) + .WithOpenBracketToken(Token(TriviaList(Comment($"/// The backing field for .")), SyntaxKind.OpenBracketToken, TriviaList()))); + + declarations.Add(cancelCommandFieldDeclaration); + } // Construct the generated property as follows (the explicit delegate cast is needed to avoid overload resolution conflicts): // @@ -393,7 +421,7 @@ public static ImmutableArray GetSyntax(CommandInfo comm ArrowExpressionClause( AssignmentExpression( SyntaxKind.CoalesceAssignmentExpression, - IdentifierName(cancelCommandFieldName), + cancelCommandFieldName is not null ? IdentifierName(cancelCommandFieldName) : IdentifierName("field"), InvocationExpression( MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, @@ -402,10 +430,11 @@ public static ImmutableArray GetSyntax(CommandInfo comm .AddArgumentListArguments(Argument(IdentifierName(commandInfo.PropertyName)))))) .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)); - return ImmutableArray.Create(fieldDeclaration, propertyDeclaration, cancelCommandFieldDeclaration, cancelCommandPropertyDeclaration); + declarations.Add(cancelCommandPropertyDeclaration); + } - return ImmutableArray.Create(fieldDeclaration, propertyDeclaration); + return declarations.ToImmutable(); } /// @@ -760,6 +789,43 @@ private static bool TryGetIncludeCancelCommandSwitch( } } + /// + /// Checks whether or not the user has requested to force using a backing field. + /// + /// The instance the method was annotated with. + /// The instance for the current run. + /// Whether or not to use a backing field. + /// Whether or not a value for could be retrieved successfully. + private static bool TryGetUseBackingField( + AttributeData attributeData, + SemanticModel semanticModel, + out bool useBackingField) + { + // Try to get the custom switch for cancel command generation (the default is false) + if (!attributeData.TryGetNamedArgument("ForceBackingField", out useBackingField)) + { + useBackingField = false; + } + + // We can only use the field keyword as the generated field name if the language version is C# 14 or greater, or if it's C# 13 and the preview features are enabled. + // Otherwise, we must use a backing field. +#if ROSLYN_5_0_0_OR_GREATER + if (!semanticModel.Compilation.HasLanguageVersionAtLeastEqualTo(LanguageVersion.CSharp14)) + { + useBackingField = true; + } +#elif ROSLYN_4_12_0_OR_GREATER + if (!semanticModel.Compilation.IsLanguageVersionPreview()) + { + useBackingField = true; + } +#else + useBackingField = true; +#endif + + return true; + } + /// /// Tries to get the expression type for the "CanExecute" property, if available. /// diff --git a/src/CommunityToolkit.Mvvm/Input/Attributes/RelayCommandAttribute.cs b/src/CommunityToolkit.Mvvm/Input/Attributes/RelayCommandAttribute.cs index 0a1d7f6b2..298c6a217 100644 --- a/src/CommunityToolkit.Mvvm/Input/Attributes/RelayCommandAttribute.cs +++ b/src/CommunityToolkit.Mvvm/Input/Attributes/RelayCommandAttribute.cs @@ -116,4 +116,9 @@ public sealed class RelayCommandAttribute : Attribute /// /// Using this property is not valid if the target command doesn't map to a cancellable asynchronous command. public bool IncludeCancelCommand { get; init; } + + /// + /// Gets or sets a value indicating whether or not a backing field should generated regardless of the availablity of the keyword. + /// + public bool ForceBackingField { get; init; } } diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservablePropertyAttribute.cs b/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservablePropertyAttribute.cs index 646058a7a..3a12701b3 100644 --- a/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservablePropertyAttribute.cs +++ b/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservablePropertyAttribute.cs @@ -1003,6 +1003,7 @@ public void Test_ObservableProperty_WithExplicitAttributeForProperty() Assert.AreEqual((Animal)67, testAttribute2.Animal); } +#if !ROSLYN_4_12_0_OR_GREATER // See https://github.com/CommunityToolkit/dotnet/issues/446 [TestMethod] public void Test_ObservableProperty_CommandNamesThatCantBeLowered() @@ -1021,6 +1022,7 @@ public void Test_ObservableProperty_CommandNamesThatCantBeLowered() Assert.AreSame(model.c中文Command, fieldInfo?.GetValue(model)); } +#endif // See https://github.com/CommunityToolkit/dotnet/issues/375 [TestMethod] diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/Test_RelayCommandAttribute.cs b/tests/CommunityToolkit.Mvvm.UnitTests/Test_RelayCommandAttribute.cs index 24293805a..8fa2e679c 100644 --- a/tests/CommunityToolkit.Mvvm.UnitTests/Test_RelayCommandAttribute.cs +++ b/tests/CommunityToolkit.Mvvm.UnitTests/Test_RelayCommandAttribute.cs @@ -571,6 +571,7 @@ public void Test_RelayCommandAttribute_CanExecuteWithNullabilityAnnotations() [TestMethod] public void Test_RelayCommandAttribute_WithExplicitAttributesForFieldAndProperty() { +#if !ROSLYN_4_12_0_OR_GREATER FieldInfo fooField = typeof(MyViewModelWithExplicitFieldAndPropertyAttributes).GetField("fooCommand", BindingFlags.Instance | BindingFlags.NonPublic)!; Assert.IsNotNull(fooField.GetCustomAttribute()); @@ -579,6 +580,7 @@ public void Test_RelayCommandAttribute_WithExplicitAttributesForFieldAndProperty Assert.IsNotNull(fooField.GetCustomAttribute()); Assert.AreEqual(100, fooField.GetCustomAttribute()!.Length); +#endif PropertyInfo fooProperty = typeof(MyViewModelWithExplicitFieldAndPropertyAttributes).GetProperty("FooCommand")!; Assert.IsNotNull(fooProperty.GetCustomAttribute()); @@ -618,17 +620,21 @@ static void ValidateTestAttribute(TestValidationAttribute testAttribute) Assert.AreEqual(Test_ObservablePropertyAttribute.Animal.Llama, testAttribute.Animal); } +#if !ROSLYN_4_12_0_OR_GREATER FieldInfo fooBarField = typeof(MyViewModelWithExplicitFieldAndPropertyAttributes).GetField("fooBarCommand", BindingFlags.Instance | BindingFlags.NonPublic)!; ValidateTestAttribute(fooBarField.GetCustomAttribute()!); +#endif PropertyInfo fooBarProperty = typeof(MyViewModelWithExplicitFieldAndPropertyAttributes).GetProperty("FooBarCommand")!; ValidateTestAttribute(fooBarProperty.GetCustomAttribute()!); +#if !ROSLYN_4_12_0_OR_GREATER FieldInfo barBazField = typeof(MyViewModelWithExplicitFieldAndPropertyAttributes).GetField("barBazCommand", BindingFlags.Instance | BindingFlags.NonPublic)!; Assert.IsNotNull(barBazField.GetCustomAttribute()); +#endif PropertyInfo barBazCommand = typeof(MyViewModelWithExplicitFieldAndPropertyAttributes).GetProperty("BarBazCommand")!; @@ -670,11 +676,13 @@ public void Test_RelayCommandAttribute_WithPartialCommandMethodDefinitions() _ = Assert.IsInstanceOfType(model.BazCommand); _ = Assert.IsInstanceOfType(model.FooBarCommand); +#if !ROSLYN_4_12_0_OR_GREATER FieldInfo bazField = typeof(ModelWithPartialCommandMethods).GetField("bazCommand", BindingFlags.Instance | BindingFlags.NonPublic)!; Assert.IsNotNull(bazField.GetCustomAttribute()); Assert.IsNotNull(bazField.GetCustomAttribute()); Assert.AreEqual(1, bazField.GetCustomAttribute()!.Length); +#endif PropertyInfo bazProperty = typeof(ModelWithPartialCommandMethods).GetProperty("BazCommand")!; @@ -682,11 +690,13 @@ public void Test_RelayCommandAttribute_WithPartialCommandMethodDefinitions() Assert.AreEqual(2, bazProperty.GetCustomAttribute()!.Length); Assert.IsNotNull(bazProperty.GetCustomAttribute()); +#if !ROSLYN_4_12_0_OR_GREATER FieldInfo fooBarField = typeof(ModelWithPartialCommandMethods).GetField("fooBarCommand", BindingFlags.Instance | BindingFlags.NonPublic)!; Assert.IsNotNull(fooBarField.GetCustomAttribute()); Assert.IsNotNull(fooBarField.GetCustomAttribute()); - Assert.AreEqual(1, fooBarField.GetCustomAttribute()!.Length); + Assert.AreEqual(1, fooBarField.GetCustomAttribute()!.Length); +#endif PropertyInfo fooBarProperty = typeof(ModelWithPartialCommandMethods).GetProperty("FooBarCommand")!;