Skip to content

Commit 81e99a9

Browse files
authored
Merge pull request #128 from CommunityToolkit/dev/cancel-commands
Add cancel command support
2 parents 89fdd0a + 1371da1 commit 81e99a9

File tree

15 files changed

+616
-7
lines changed

15 files changed

+616
-7
lines changed

CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Unshipped.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ MVVMTK0009 | CommunityToolkit.Mvvm.SourceGenerators.ICommandGenerator | Error |
1717
MVVMTK0010 | CommunityToolkit.Mvvm.SourceGenerators.ICommandGenerator | Error | See https://aka.ms/mvvmtoolkit/error
1818
MVVMTK0011 | CommunityToolkit.Mvvm.SourceGenerators.ICommandGenerator | Error | See https://aka.ms/mvvmtoolkit/error
1919
MVVMTK0012 | CommunityToolkit.Mvvm.SourceGenerators.ICommandGenerator | Error | See https://aka.ms/mvvmtoolkit/error
20+
MVVMTK0013 | CommunityToolkit.Mvvm.SourceGenerators.ICommandGenerator | Error | See https://aka.ms/mvvmtoolkit/error

CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,4 +203,20 @@ internal static class DiagnosticDescriptors
203203
isEnabledByDefault: true,
204204
description: "Cannot apply the [ICommand] attribute specifying a concurrency control setting to methods mapping to non-asynchronous command types.",
205205
helpLinkUri: "https://aka.ms/mvvmtoolkit");
206+
207+
/// <summary>
208+
/// Gets a <see cref="DiagnosticDescriptor"/> indicating when <c>ICommandAttribute.IncludeCancelCommandParameter</c> is being set for an invalid method.
209+
/// <para>
210+
/// Format: <c>"The method {0}.{1} cannot be annotated with the [ICommand] attribute specifying to include a cancel command, as it does not map to an asynchronous command type taking a cancellation token"</c>.
211+
/// </para>
212+
/// </summary>
213+
public static readonly DiagnosticDescriptor InvalidIncludeCancelCommandParameterError = new DiagnosticDescriptor(
214+
id: "MVVMTK0013",
215+
title: "Invalid concurrency control setting usage",
216+
messageFormat: "The method {0}.{1} cannot be annotated with the [ICommand] attribute specifying to include a cancel command, as it does not map to an asynchronous command type taking a cancellation token",
217+
category: typeof(ICommandGenerator).FullName,
218+
defaultSeverity: DiagnosticSeverity.Error,
219+
isEnabledByDefault: true,
220+
description: "Cannot apply the [ICommand] attribute specifying to include a cancel command to methods not mapping to an asynchronous command type accepting a cancellation token.",
221+
helpLinkUri: "https://aka.ms/mvvmtoolkit");
206222
}

CommunityToolkit.Mvvm.SourceGenerators/Input/ICommandGenerator.Execute.cs

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ private static class Execute
4646
out string? commandInterfaceType,
4747
out string? commandClassType,
4848
out string? delegateType,
49+
out bool supportsCancellation,
4950
out ImmutableArray<string> commandTypeArguments,
5051
out ImmutableArray<string> delegateTypeArguments))
5152
{
@@ -75,6 +76,18 @@ private static class Execute
7576
goto Failure;
7677
}
7778

79+
// Get the option to include a cancel command, if any
80+
if (!TryGetIncludeCancelCommandSwitch(
81+
methodSymbol,
82+
attributeData,
83+
commandClassType,
84+
supportsCancellation,
85+
builder,
86+
out bool generateCancelCommand))
87+
{
88+
goto Failure;
89+
}
90+
7891
diagnostics = builder.ToImmutable();
7992

8093
return new(
@@ -88,7 +101,8 @@ private static class Execute
88101
delegateTypeArguments,
89102
canExecuteMemberName,
90103
canExecuteExpressionType,
91-
allowConcurrentExecutions);
104+
allowConcurrentExecutions,
105+
generateCancelCommand);
92106

93107
Failure:
94108
diagnostics = builder.ToImmutable();
@@ -222,6 +236,71 @@ public static ImmutableArray<MemberDeclarationSyntax> GetSyntax(CommandInfo comm
222236
.AddArgumentListArguments(commandCreationArguments.ToArray()))))
223237
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken));
224238

239+
// Conditionally declare the additional members for the cancel commands
240+
if (commandInfo.IncludeCancelCommand)
241+
{
242+
// Prepare all necessary member and type names
243+
string cancelCommandFieldName = $"{commandInfo.FieldName.Substring(0, commandInfo.FieldName.Length - "Command".Length)}CancelCommand";
244+
string cancelCommandPropertyName = $"{commandInfo.PropertyName.Substring(0, commandInfo.PropertyName.Length - "Command".Length)}CancelCommand";
245+
246+
// Construct the generated field for the cancel command as follows:
247+
//
248+
// /// <summary>The backing field for <see cref="<COMMAND_PROPERTY_NAME>"/></summary>
249+
// [global::System.CodeDom.Compiler.GeneratedCode("...", "...")]
250+
// private global::System.Windows.Input.ICommand? <CANCEL_COMMAND_FIELD_NAME>;
251+
FieldDeclarationSyntax cancelCommandFieldDeclaration =
252+
FieldDeclaration(
253+
VariableDeclaration(NullableType(IdentifierName("global::System.Windows.Input.ICommand")))
254+
.AddVariables(VariableDeclarator(Identifier(cancelCommandFieldName))))
255+
.AddModifiers(Token(SyntaxKind.PrivateKeyword))
256+
.AddAttributeLists(
257+
AttributeList(SingletonSeparatedList(
258+
Attribute(IdentifierName("global::System.CodeDom.Compiler.GeneratedCode"))
259+
.AddArgumentListArguments(
260+
AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(ICommandGenerator).FullName))),
261+
AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(ICommandGenerator).Assembly.GetName().Version.ToString()))))))
262+
.WithOpenBracketToken(Token(TriviaList(Comment($"/// <summary>The backing field for <see cref=\"{cancelCommandPropertyName}\"/>.</summary>")), SyntaxKind.OpenBracketToken, TriviaList())));
263+
264+
// Construct the generated property as follows (the explicit delegate cast is needed to avoid overload resolution conflicts):
265+
//
266+
// /// <summary>Gets an <see cref="global::System.Windows.Input.ICommand" instance that can be used to cancel <see cref="<COMMAND_PROPERTY_NAME>"/>.</summary>
267+
// [global::System.CodeDom.Compiler.GeneratedCode("...", "...")]
268+
// [global::System.Diagnostics.DebuggerNonUserCode]
269+
// [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
270+
// public global::System.Windows.Input.ICommand <CANCEL_COMMAND_PROPERTY_NAME> => <CANCEL_COMMAND_FIELD_NAME> ??= global::CommunityToolkit.Mvvm.Input.IAsyncRelayCommandExtensions.CreateCancelCommand(<COMMAND_PROPERTY_NAME>);
271+
PropertyDeclarationSyntax cancelCommandPropertyDeclaration =
272+
PropertyDeclaration(
273+
IdentifierName("global::System.Windows.Input.ICommand"),
274+
Identifier(cancelCommandPropertyName))
275+
.AddModifiers(Token(SyntaxKind.PublicKeyword))
276+
.AddAttributeLists(
277+
AttributeList(SingletonSeparatedList(
278+
Attribute(IdentifierName("global::System.CodeDom.Compiler.GeneratedCode"))
279+
.AddArgumentListArguments(
280+
AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(ICommandGenerator).FullName))),
281+
AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(ICommandGenerator).Assembly.GetName().Version.ToString()))))))
282+
.WithOpenBracketToken(Token(TriviaList(Comment(
283+
$"/// <summary>Gets an <see cref=\"global::System.Windows.Input.ICommand\"/> instance that can be used to cancel <see cref=\"{commandInfo.PropertyName}\"/>.</summary>")),
284+
SyntaxKind.OpenBracketToken,
285+
TriviaList())),
286+
AttributeList(SingletonSeparatedList(Attribute(IdentifierName("global::System.Diagnostics.DebuggerNonUserCode")))),
287+
AttributeList(SingletonSeparatedList(Attribute(IdentifierName("global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage")))))
288+
.WithExpressionBody(
289+
ArrowExpressionClause(
290+
AssignmentExpression(
291+
SyntaxKind.CoalesceAssignmentExpression,
292+
IdentifierName(cancelCommandFieldName),
293+
InvocationExpression(
294+
MemberAccessExpression(
295+
SyntaxKind.SimpleMemberAccessExpression,
296+
IdentifierName("global::CommunityToolkit.Mvvm.Input.IAsyncRelayCommandExtensions"),
297+
IdentifierName("CreateCancelCommand")))
298+
.AddArgumentListArguments(Argument(IdentifierName(commandInfo.PropertyName))))))
299+
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken));
300+
301+
return ImmutableArray.Create<MemberDeclarationSyntax>(fieldDeclaration, propertyDeclaration, cancelCommandFieldDeclaration, cancelCommandPropertyDeclaration);
302+
}
303+
225304
return ImmutableArray.Create<MemberDeclarationSyntax>(fieldDeclaration, propertyDeclaration);
226305
}
227306

@@ -255,6 +334,7 @@ private static (string FieldName, string PropertyName) GetGeneratedFieldAndPrope
255334
/// <param name="commandInterfaceType">The command interface type name.</param>
256335
/// <param name="commandClassType">The command class type name.</param>
257336
/// <param name="delegateType">The delegate type name for the wrapped method.</param>
337+
/// <param name="supportsCancellation">Indicates whether or not the resulting command supports cancellation.</param>
258338
/// <param name="commandTypeArguments">The type arguments for <paramref name="commandInterfaceType"/> and <paramref name="commandClassType"/>, if any.</param>
259339
/// <param name="delegateTypeArguments">The type arguments for <paramref name="delegateType"/>, if any.</param>
260340
/// <returns>Whether or not <paramref name="methodSymbol"/> was valid and the requested types have been set.</returns>
@@ -264,6 +344,7 @@ private static bool TryMapCommandTypesFromMethod(
264344
[NotNullWhen(true)] out string? commandInterfaceType,
265345
[NotNullWhen(true)] out string? commandClassType,
266346
[NotNullWhen(true)] out string? delegateType,
347+
out bool supportsCancellation,
267348
out ImmutableArray<string> commandTypeArguments,
268349
out ImmutableArray<string> delegateTypeArguments)
269350
{
@@ -273,6 +354,7 @@ private static bool TryMapCommandTypesFromMethod(
273354
commandInterfaceType = "global::CommunityToolkit.Mvvm.Input.IRelayCommand";
274355
commandClassType = "global::CommunityToolkit.Mvvm.Input.RelayCommand";
275356
delegateType = "global::System.Action";
357+
supportsCancellation = false;
276358
commandTypeArguments = ImmutableArray<string>.Empty;
277359
delegateTypeArguments = ImmutableArray<string>.Empty;
278360

@@ -287,6 +369,7 @@ private static bool TryMapCommandTypesFromMethod(
287369
commandInterfaceType = "global::CommunityToolkit.Mvvm.Input.IRelayCommand";
288370
commandClassType = "global::CommunityToolkit.Mvvm.Input.RelayCommand";
289371
delegateType = "global::System.Action";
372+
supportsCancellation = false;
290373
commandTypeArguments = ImmutableArray.Create(parameter.Type.GetFullyQualifiedName());
291374
delegateTypeArguments = ImmutableArray.Create(parameter.Type.GetFullyQualifiedName());
292375

@@ -301,6 +384,7 @@ private static bool TryMapCommandTypesFromMethod(
301384
commandInterfaceType = "global::CommunityToolkit.Mvvm.Input.IAsyncRelayCommand";
302385
commandClassType = "global::CommunityToolkit.Mvvm.Input.AsyncRelayCommand";
303386
delegateType = "global::System.Func";
387+
supportsCancellation = false;
304388
commandTypeArguments = ImmutableArray<string>.Empty;
305389
delegateTypeArguments = ImmutableArray.Create("global::System.Threading.Tasks.Task");
306390

@@ -316,8 +400,9 @@ private static bool TryMapCommandTypesFromMethod(
316400
commandInterfaceType = "global::CommunityToolkit.Mvvm.Input.IAsyncRelayCommand";
317401
commandClassType = "global::CommunityToolkit.Mvvm.Input.AsyncRelayCommand";
318402
delegateType = "global::System.Func";
403+
supportsCancellation = true;
319404
commandTypeArguments = ImmutableArray<string>.Empty;
320-
delegateTypeArguments = ImmutableArray.Create("global::System.Threading.CancellationToken", "global::System.Threading.Tasks.Task");
405+
delegateTypeArguments = ImmutableArray.Create(singleParameter.Type.GetFullyQualifiedName(), "global::System.Threading.Tasks.Task");
321406

322407
return true;
323408
}
@@ -326,6 +411,7 @@ private static bool TryMapCommandTypesFromMethod(
326411
commandInterfaceType = "global::CommunityToolkit.Mvvm.Input.IAsyncRelayCommand";
327412
commandClassType = "global::CommunityToolkit.Mvvm.Input.AsyncRelayCommand";
328413
delegateType = "global::System.Func";
414+
supportsCancellation = false;
329415
commandTypeArguments = ImmutableArray.Create(singleParameter.Type.GetFullyQualifiedName());
330416
delegateTypeArguments = ImmutableArray.Create(singleParameter.Type.GetFullyQualifiedName(), "global::System.Threading.Tasks.Task");
331417

@@ -341,6 +427,7 @@ private static bool TryMapCommandTypesFromMethod(
341427
commandInterfaceType = "global::CommunityToolkit.Mvvm.Input.IAsyncRelayCommand";
342428
commandClassType = "global::CommunityToolkit.Mvvm.Input.AsyncRelayCommand";
343429
delegateType = "global::System.Func";
430+
supportsCancellation = true;
344431
commandTypeArguments = ImmutableArray.Create(firstParameter.Type.GetFullyQualifiedName());
345432
delegateTypeArguments = ImmutableArray.Create(firstParameter.Type.GetFullyQualifiedName(), secondParameter.Type.GetFullyQualifiedName(), "global::System.Threading.Tasks.Task");
346433

@@ -353,6 +440,7 @@ private static bool TryMapCommandTypesFromMethod(
353440
commandInterfaceType = null;
354441
commandClassType = null;
355442
delegateType = null;
443+
supportsCancellation = false;
356444
commandTypeArguments = ImmutableArray<string>.Empty;
357445
delegateTypeArguments = ImmutableArray<string>.Empty;
358446

@@ -397,6 +485,47 @@ private static bool TryGetAllowConcurrentExecutionsSwitch(
397485
}
398486
}
399487

488+
/// <summary>
489+
/// Checks whether or not the user has requested to also generate a cancel command.
490+
/// </summary>
491+
/// <param name="methodSymbol">The input <see cref="IMethodSymbol"/> instance to process.</param>
492+
/// <param name="attributeData">The <see cref="AttributeData"/> instance the method was annotated with.</param>
493+
/// <param name="commandClassType">The command class type name.</param>
494+
/// <param name="supportsCancellation">Indicates whether or not the command supports cancellation.</param>
495+
/// <param name="diagnostics">The current collection of gathered diagnostics.</param>
496+
/// <param name="generateCancelCommand">Whether or not concurrent executions have been enabled.</param>
497+
/// <returns>Whether or not a value for <paramref name="generateCancelCommand"/> could be retrieved successfully.</returns>
498+
private static bool TryGetIncludeCancelCommandSwitch(
499+
IMethodSymbol methodSymbol,
500+
AttributeData attributeData,
501+
string commandClassType,
502+
bool supportsCancellation,
503+
ImmutableArray<Diagnostic>.Builder diagnostics,
504+
out bool generateCancelCommand)
505+
{
506+
// Try to get the custom switch for cancel command generation (the default is false)
507+
if (!attributeData.TryGetNamedArgument("IncludeCancelCommand", out generateCancelCommand))
508+
{
509+
generateCancelCommand = false;
510+
511+
return true;
512+
}
513+
514+
// If the current type is an async command type and cancellation is supported, pass that value to the constructor.
515+
// Otherwise, the current attribute use is not valid, so a diagnostic message should be produced.
516+
if (commandClassType is "global::CommunityToolkit.Mvvm.Input.AsyncRelayCommand" &&
517+
supportsCancellation)
518+
{
519+
return true;
520+
}
521+
else
522+
{
523+
diagnostics.Add(InvalidIncludeCancelCommandParameterError, methodSymbol, methodSymbol.ContainingType, methodSymbol);
524+
525+
return false;
526+
}
527+
}
528+
400529
/// <summary>
401530
/// Tries to get the expression type for the "CanExecute" property, if available.
402531
/// </summary>

CommunityToolkit.Mvvm.SourceGenerators/Input/Models/CommandInfo.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ namespace CommunityToolkit.Mvvm.SourceGenerators.Input.Models;
2424
/// <param name="DelegateTypeArguments">The type arguments for <paramref name="DelegateType"/>, if any.</param>
2525
/// <param name="CanExecuteMemberName">The member name for the can execute check, if available.</param>
2626
/// <param name="CanExecuteExpressionType">The can execute expression type, if available.</param>
27-
/// <param name="AllowConcurrentExecutions">Whether or not concurrent executions have been disabled.</param>
27+
/// <param name="AllowConcurrentExecutions">Whether or not concurrent executions have been enabled.</param>
28+
/// <param name="IncludeCancelCommand">Whether or not to also generate a cancel command.</param>
2829
internal sealed record CommandInfo(
2930
string MethodName,
3031
string FieldName,
@@ -36,7 +37,8 @@ internal sealed record CommandInfo(
3637
ImmutableArray<string> DelegateTypeArguments,
3738
string? CanExecuteMemberName,
3839
CanExecuteExpressionType? CanExecuteExpressionType,
39-
bool AllowConcurrentExecutions)
40+
bool AllowConcurrentExecutions,
41+
bool IncludeCancelCommand)
4042
{
4143
/// <summary>
4244
/// An <see cref="IEqualityComparer{T}"/> implementation for <see cref="CommandInfo"/>.
@@ -57,6 +59,7 @@ protected override void AddToHashCode(ref HashCode hashCode, CommandInfo obj)
5759
hashCode.Add(obj.CanExecuteMemberName);
5860
hashCode.Add(obj.CanExecuteExpressionType);
5961
hashCode.Add(obj.AllowConcurrentExecutions);
62+
hashCode.Add(obj.IncludeCancelCommand);
6063
}
6164

6265
/// <inheritdoc/>
@@ -73,7 +76,8 @@ protected override bool AreEqual(CommandInfo x, CommandInfo y)
7376
x.DelegateTypeArguments.SequenceEqual(y.CommandTypeArguments) &&
7477
x.CanExecuteMemberName == y.CanExecuteMemberName &&
7578
x.CanExecuteExpressionType == y.CanExecuteExpressionType &&
76-
x.AllowConcurrentExecutions == y.AllowConcurrentExecutions;
79+
x.AllowConcurrentExecutions == y.AllowConcurrentExecutions &&
80+
x.IncludeCancelCommand == y.IncludeCancelCommand;
7781
}
7882
}
7983
}

0 commit comments

Comments
 (0)