Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .nuke/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@
"allOf": [
{
"properties": {
"BclNuGetApiKey": {
"type": "string",
"description": "NuGet API key for MvvmAIO.Prism.Bcl.Commands (optional; falls back to --nuget-api-key)"
},
"Configuration": {
"type": "string",
"description": "Build configuration (Debug/Release)"
Expand Down
16 changes: 16 additions & 0 deletions AGENTS.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# AGENTS.zh-CN.md

面向中文贡献者与自动化代理的**摘要**。[**AGENTS.md**](AGENTS.md)(英文)为唯一 canonical 约束全文;若冲突以英文为准。

## 要点

1. **构建与 CI**:`dotnet run --project build/_build.csproj -- --target Ci --configuration Release`(或 `dotnet build` / `dotnet test`)。
2. **解决方案**:使用根目录 **`.slnx`**,勿默认新建 `.sln`。
3. **临时文件**:实验与一次性项目放在 **`.Temp/`**(已 gitignore,勿提交)。
4. **GitHub 流程**:先 Issue → 分支 PR → squash merge 到 `master`;PR 正文链接 Issue(`Fixes #NN`)。
5. **生成器变更**:同步测试(含 Verify 快照)、`CHANGELOG.md`、三语 README / wiki / [Docs 仓](https://github.com/MvvmAIO/Prism.SourceGenerators.Docs) 用户可见页。
6. **NuGet**:主包 **MvvmAIO.Prism.SourceGenerators**;Prism 8 异步命令另需 **MvvmAIO.Prism.Bcl.Commands**(独立发布密钥 `NUGET_API_KEY_BCL`)。
7. **Roslyn 变体**:`Roslyn4001` … `Roslyn5000` 多目标;Roslyn 5.0 冒烟见 `Prism.SourceGenerators.Tests.Roslyn5000`。
8. **文档站点**:https://mvvmaio.github.io/Prism.SourceGenerators.Docs/

完整目录结构、诊断清单与发布步骤见 [**AGENTS.md**](AGENTS.md)。
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to this project are documented in this file.

## Unreleased

## [0.5.0] - 2026-05-26

### Added

- **`[DelegateCommand]` / `[AsyncDelegateCommand]`** now support execute methods returning **`Task<TResult>`** (result is awaited; not surfaced on the command). Closes [#56](https://github.com/MvvmAIO/Prism.SourceGenerators/issues/56).
- Additional **Roslyn 5.0** smoke tests for delegate commands.

### Changed

- Pack: suppress **NU5128** for bundled `MvvmAIO.Prism.Core` under `lib/netstandard2.0` (not a separate nuspec dependency).

## [0.4.3] - 2026-05-25

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>

<PackageId>MvvmAIO.Prism.SourceGenerators</PackageId>
<Version>0.4.3</Version>
<Version>0.5.0</Version>
<Authors>MvvmAIO;Skymly;wys0610</Authors>
<Description>Roslyn source generators for the Prism MVVM library. Generates ObservableProperty, DelegateCommand, AsyncDelegateCommand, and BindableBase implementations. This package contains Prism.SourceGenerators analyzers and MvvmAIO.Prism.Core (attribute definitions). For Prism.Core 8.1.97 async commands, install MvvmAIO.Prism.Bcl.Commands manually.</Description>
<PackageTags>prism;mvvm;source-generator;roslyn;wpf;observable-property;delegate-command</PackageTags>
Expand All @@ -21,6 +21,8 @@
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<!-- MvvmAIO.Prism.Core is bundled under lib/netstandard2.0; not a separate package dependency (NU5128). -->
<NoWarn>$(NoWarn);NU5128</NoWarn>
</PropertyGroup>

<ItemGroup>
Expand Down
110 changes: 101 additions & 9 deletions Prism.SourceGenerators.Tests.Roslyn5000/Roslyn5000SmokeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,100 @@ public partial class Vm : Prism.Mvvm.BindableBase
Assert.Contains("public string Title", propertySource.Source);
Assert.Contains("SetProperty", propertySource.Source);
}

[Fact]
public void DelegateCommand_Task_execute_on_Roslyn5000_emits_AsyncDelegateCommand()
{
const string userSource = """
namespace Demo;

public partial class Vm : Prism.Mvvm.BindableBase
{
[DelegateCommand]
private async System.Threading.Tasks.Task SaveAsync()
{
await System.Threading.Tasks.Task.CompletedTask;
}
}
""";

GeneratorRunOutput output = Roslyn5000TestHarness.Run(userSource, includeCommands: true);

Assert.Empty(output.Diagnostics.Where(static d =>
d.Severity >= DiagnosticSeverity.Error &&
d.Id.StartsWith("PSG", System.StringComparison.Ordinal)));

GeneratedSource commandSource = Assert.Single(
output.GeneratedSources.Where(s => s.HintName.EndsWith(".SaveCommand.g.cs")));

Assert.Contains("AsyncDelegateCommand", commandSource.Source);
Assert.Contains("SaveAsync", commandSource.Source);
}

[Fact]
public void DelegateCommand_TaskOfT_execute_on_Roslyn5000_emits_await_wrapper()
{
const string userSource = """
namespace Demo;

public partial class Vm : Prism.Mvvm.BindableBase
{
[DelegateCommand]
private async System.Threading.Tasks.Task<int> CountAsync()
{
await System.Threading.Tasks.Task.CompletedTask;
return 0;
}
}
""";

GeneratorRunOutput output = Roslyn5000TestHarness.Run(userSource, includeCommands: true);

Assert.Empty(output.Diagnostics.Where(static d =>
d.Severity >= DiagnosticSeverity.Error &&
d.Id.StartsWith("PSG", System.StringComparison.Ordinal)));

GeneratedSource commandSource = Assert.Single(
output.GeneratedSources.Where(s => s.HintName.EndsWith(".CountCommand.g.cs")));

Assert.Contains("async () => await CountAsync()", commandSource.Source);
}
}

internal static class Roslyn5000TestHarness
{
internal static GeneratorRunOutput Run(string userSource)
internal static GeneratorRunOutput Run(string userSource, bool includeCommands = false)
{
string commandStubs = includeCommands
? """

namespace Prism.Commands
{
public class DelegateCommand
{
public DelegateCommand(System.Action execute) { }
public DelegateCommand(System.Func<System.Threading.Tasks.Task> execute) { }
}

public class AsyncDelegateCommand
{
public AsyncDelegateCommand(System.Func<System.Threading.Tasks.Task> execute) { }
public AsyncDelegateCommand(System.Func<System.Threading.Tasks.Task> execute, System.Func<bool> canExecute) { }
}

public class AsyncDelegateCommand<T>
{
public AsyncDelegateCommand(System.Func<T, System.Threading.Tasks.Task> execute) { }
public AsyncDelegateCommand(System.Func<T, System.Threading.Tasks.Task> execute, System.Func<T, bool> canExecute) { }
}
}
"""
: string.Empty;

string harness = """
#nullable enable
using System;
using System.Threading.Tasks;
using Prism.SourceGenerators;

namespace Prism.Mvvm
Expand All @@ -58,8 +143,7 @@ public abstract class BindableBase : System.ComponentModel.INotifyPropertyChange
protected void RaisePropertyChanged(string? propertyName = null) { }
}
}

""" + userSource;
""" + commandStubs + userSource;

SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(
harness,
Expand All @@ -71,12 +155,20 @@ protected void RaisePropertyChanged(string? propertyName = null) { }
Roslyn5000MetadataReferences.Get(),
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

IIncrementalGenerator[] generators =
[
new ObservablePropertyGenerator(),
new PropertyChangingGenerator(),
new BindableBaseGenerator(),
];
IIncrementalGenerator[] generators = includeCommands
?
[
new ObservablePropertyGenerator(),
new PropertyChangingGenerator(),
new BindableBaseGenerator(),
new DelegateCommandGenerator(),
]
:
[
new ObservablePropertyGenerator(),
new PropertyChangingGenerator(),
new BindableBaseGenerator(),
];

GeneratorDriver driver = CSharpGeneratorDriver.Create(
generators.Select(static g => g.AsSourceGenerator()),
Expand Down
25 changes: 25 additions & 0 deletions Prism.SourceGenerators.Tests/MatrixTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,31 @@ private async System.Threading.Tasks.ValueTask SaveAsync()
Assert.Contains("() => SaveAsync().AsTask()", commandSource.Source);
}

[Fact]
public void DelegateCommand_TaskOfT_execute_emits_await_wrapper()
{
const string source = """
namespace Demo;

public partial class Vm : Prism.Mvvm.BindableBase
{
[DelegateCommand]
private async System.Threading.Tasks.Task<int> CountAsync()
{
await System.Threading.Tasks.Task.CompletedTask;
return 1;
}
}
""";

GeneratorRunOutput output = GeneratorTestHarness.Run(source, languageVersion: LanguageVersion.Preview);
Assert.False(output.Diagnostics.Any(static d => d.Id is "PSG1001" or "PSG1002"));

GeneratedSource commandSource = Assert.Single(
output.GeneratedSources.Where(s => s.HintName.EndsWith(".CountCommand.g.cs")));
Assert.Contains("async () => await CountAsync()", commandSource.Source);
}

[Fact]
public void AsyncDelegateCommand_ValueTaskOfT_execute_emits_parameterized_AsTask_wrapper()
{
Expand Down
48 changes: 38 additions & 10 deletions Prism.SourceGenerators/DelegateCommandGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace Prism.SourceGenerators;
/// from methods annotated with <c>[DelegateCommand]</c> or <c>[AsyncDelegateCommand]</c>.
/// <para>
/// For synchronous methods (<c>void</c>), generates <c>DelegateCommand</c> or <c>DelegateCommand&lt;T&gt;</c>.
/// For asynchronous methods (<c>Task</c>, <c>ValueTask</c>, or <c>ValueTask&lt;TResult&gt;</c>), generates <c>AsyncDelegateCommand</c> or <c>AsyncDelegateCommand&lt;T&gt;</c>.
/// For asynchronous methods (<c>Task</c>, <c>Task&lt;TResult&gt;</c>, <c>ValueTask</c>, or <c>ValueTask&lt;TResult&gt;</c>), generates <c>AsyncDelegateCommand</c> or <c>AsyncDelegateCommand&lt;T&gt;</c>.
/// For Prism versions prior to 9.0, use NuGet <c>MvvmAIO.Prism.SourceGenerators</c> and install <c>MvvmAIO.Prism.Bcl.Commands</c> manually for Prism.Core 8.1.97 (see diagnostic PSG3002).
/// </para>
/// </summary>
Expand Down Expand Up @@ -168,6 +168,7 @@ private static Result<CommandGenerationInfo> ExtractDelegateCommandInfo(

bool useFieldKeyword = SupportsFieldKeyword(context);
bool wrapAsyncExecuteWithAsTask = isAsync && IsValueTaskReturnFamily(methodSymbol.ReturnType, compilation);
bool wrapAsyncExecuteWithAwaitLambda = isAsync && IsTaskOfTReturnFamily(methodSymbol.ReturnType, compilation);

if (canExecute is not null && !HasMember(containingType, canExecute))
{
Expand Down Expand Up @@ -216,7 +217,8 @@ private static Result<CommandGenerationInfo> ExtractDelegateCommandInfo(
EnableParallelExecution: false,
ObservesProperties: observesProperties,
UseFieldKeyword: useFieldKeyword,
WrapAsyncExecuteWithAsTask: wrapAsyncExecuteWithAsTask),
WrapAsyncExecuteWithAsTask: wrapAsyncExecuteWithAsTask,
WrapAsyncExecuteWithAwaitLambda: wrapAsyncExecuteWithAwaitLambda),
diagnostics.ToImmutable());
}

Expand All @@ -239,6 +241,7 @@ private static ImmutableArray<Result<CommandGenerationInfo>> ExtractAsyncDelegat
HierarchyInfo hierarchy = HierarchyInfo.From(containingType);
bool useFieldKeyword = SupportsFieldKeyword(context);
bool wrapAsyncExecuteWithAsTask = IsValueTaskReturnFamily(methodSymbol.ReturnType, compilation);
bool wrapAsyncExecuteWithAwaitLambda = IsTaskOfTReturnFamily(methodSymbol.ReturnType, compilation);

ImmutableArray<Result<CommandGenerationInfo>>.Builder builder =
ImmutableArray.CreateBuilder<Result<CommandGenerationInfo>>();
Expand Down Expand Up @@ -354,7 +357,8 @@ private static ImmutableArray<Result<CommandGenerationInfo>> ExtractAsyncDelegat
enableParallelExecution,
observesProperties,
useFieldKeyword,
WrapAsyncExecuteWithAsTask: wrapAsyncExecuteWithAsTask),
WrapAsyncExecuteWithAsTask: wrapAsyncExecuteWithAsTask,
WrapAsyncExecuteWithAwaitLambda: wrapAsyncExecuteWithAwaitLambda),
diagnostics.ToImmutable()));
}

Expand Down Expand Up @@ -451,8 +455,7 @@ private static bool IsValidDelegateCommandMethodSignature(IMethodSymbol methodSy
if (!IsBindingSupportedAsyncReturnType(methodSymbol.ReturnType, compilation))
return false;

if (IsValueTaskReturnFamily(methodSymbol.ReturnType, compilation)
&& methodSymbol.Parameters.Any(static p => IsCancellationToken(p.Type)))
if (UsesUnsupportedAsyncReturnWithCancellationToken(methodSymbol, compilation))
{
return false;
}
Expand All @@ -471,8 +474,7 @@ private static bool IsValidAsyncDelegateCommandMethodSignature(IMethodSymbol met
if (!IsBindingSupportedAsyncReturnType(methodSymbol.ReturnType, compilation))
return false;

if (IsValueTaskReturnFamily(methodSymbol.ReturnType, compilation)
&& methodSymbol.Parameters.Any(static p => IsCancellationToken(p.Type)))
if (UsesUnsupportedAsyncReturnWithCancellationToken(methodSymbol, compilation))
{
return false;
}
Expand Down Expand Up @@ -522,9 +524,7 @@ private static bool IsAsyncAwaitableReturnType(ITypeSymbol returnType, Compilati
}

/// <summary>
/// Async command binding supports non-generic <c>Task</c>, non-generic <c>ValueTask</c>, and <c>ValueTask&lt;TResult&gt;</c>
/// (Prism exposes matching <c>Func&lt;ValueTask&gt;</c> / <c>Func&lt;ValueTask&lt;TResult&gt;&gt;</c> overloads on <c>AsyncDelegateCommand</c>).
/// <c>Task&lt;TResult&gt;</c> is not supported for command execute methods (unchanged).
/// Async command binding supports non-generic <c>Task</c>, <c>Task&lt;TResult&gt;</c>, non-generic <c>ValueTask</c>, and <c>ValueTask&lt;TResult&gt;</c>.
/// </summary>
private static bool IsBindingSupportedAsyncReturnType(ITypeSymbol returnType, Compilation compilation)
{
Expand All @@ -539,6 +539,11 @@ private static bool IsBindingSupportedAsyncReturnType(ITypeSymbol returnType, Co
return true;
}

if (IsTaskOfTReturnFamily(returnType, compilation))
{
return true;
}

INamedTypeSymbol? valueTask = compilation.GetTypeByMetadataName("System.Threading.Tasks.ValueTask");
if (valueTask is not null && !named.IsGenericType && SymbolEqualityComparer.Default.Equals(returnType, valueTask))
{
Expand All @@ -551,6 +556,29 @@ private static bool IsBindingSupportedAsyncReturnType(ITypeSymbol returnType, Co
&& SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, valueTaskOfT);
}

private static bool IsTaskOfTReturnFamily(ITypeSymbol returnType, Compilation compilation)
{
if (returnType is not INamedTypeSymbol named || !named.IsGenericType)
{
return false;
}

INamedTypeSymbol? taskOfT = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task`1");
return taskOfT is not null
&& SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, taskOfT);
}

private static bool UsesUnsupportedAsyncReturnWithCancellationToken(IMethodSymbol methodSymbol, Compilation compilation)
{
if (!methodSymbol.Parameters.Any(static p => IsCancellationToken(p.Type)))
{
return false;
}

return IsValueTaskReturnFamily(methodSymbol.ReturnType, compilation)
|| IsTaskOfTReturnFamily(methodSymbol.ReturnType, compilation);
}

private static bool IsValueTaskReturnFamily(ITypeSymbol returnType, Compilation compilation)
{
if (returnType is not INamedTypeSymbol named)
Expand Down
17 changes: 12 additions & 5 deletions Prism.SourceGenerators/DelegateCommandSyntax.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,20 @@ info.CancellationTokenSourceFactory is not null ||

private static string GetAsyncCommandExecuteArgument(CommandGenerationInfo info)
{
if (!info.WrapAsyncExecuteWithAsTask)
if (info.WrapAsyncExecuteWithAwaitLambda)
{
return info.MethodName;
return info.ParameterType is null
? $"async () => await {info.MethodName}()"
: $"async (__p) => await {info.MethodName}(__p)";
}

return info.ParameterType is null
? $"() => {info.MethodName}().AsTask()"
: $"(__p) => {info.MethodName}(__p).AsTask()";
if (info.WrapAsyncExecuteWithAsTask)
{
return info.ParameterType is null
? $"() => {info.MethodName}().AsTask()"
: $"(__p) => {info.MethodName}(__p).AsTask()";
}

return info.MethodName;
}
}
3 changes: 2 additions & 1 deletion Prism.SourceGenerators/Models/CommandGenerationInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ internal sealed record CommandGenerationInfo(
bool EnableParallelExecution,
EquatableArray<string> ObservesProperties,
bool UseFieldKeyword,
bool WrapAsyncExecuteWithAsTask) : IEquatable<CommandGenerationInfo>;
bool WrapAsyncExecuteWithAsTask,
bool WrapAsyncExecuteWithAwaitLambda) : IEquatable<CommandGenerationInfo>;
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ public string Password { get { ... } set { ... } }
メソッドから `DelegateCommand` または `AsyncDelegateCommand` プロパティを生成します。

- **同期メソッド**(`void`)は `DelegateCommand` / `DelegateCommand<T>` を生成
- **非同期メソッド**の戻り値が非ジェネリックの **`Task`**、**`ValueTask`**、または **`ValueTask<TResult>`** のとき、`AsyncDelegateCommand` / `AsyncDelegateCommand<T>` を生成します。`ValueTask` / `ValueTask<TResult>` は生成コードで `.AsTask()` により Prism の `Func<Task>` / `Func<T, Task>` コンストラクタに接続します。**`Task<TResult>`** execute の戻り値にすることはできません(従来どおり)。**`CancellationToken`** を取る execute メソッドでは `ValueTask` / `ValueTask<TResult>` はサポートされず(**PSG1001**)。
- **非同期メソッド**の戻り値が **`Task`**、**`Task<TResult>`**、**`ValueTask`**、または **`ValueTask<TResult>`** のとき、`AsyncDelegateCommand` / `AsyncDelegateCommand<T>` を生成します。`ValueTask` / `ValueTask<TResult>` は生成コードで `.AsTask()` により Prism の `Func<Task>` / `Func<T, Task>` コンストラクタに接続します。**`Task<TResult>`** は `async` lambda で execute を待機します。**`CancellationToken`** を取る execute メソッドでは `ValueTask``ValueTask<TResult>`、**`Task<TResult>`** はサポートされず(**PSG1001**)。
- Prism &lt; 9.0 の場合、NuGet **`MvvmAIO.Prism.SourceGenerators`** を使用してください。**`MvvmAIO.Prism.Core`**(属性定義)を追加します。Prism.Core 8.1.97 の非同期コマンドを使う場合は **`MvvmAIO.Prism.Bcl.Commands`** を手動で追加してください。非同期コマンド使用時にこれらのアセンブリがない場合は **PSG3002** が報告されます。
- **C# 14+**:Command プロパティは `field` キーワードを使用(個別のバッキングフィールド不要)
- **C# 13 以前**:Command プロパティは従来のバッキングフィールドを使用
Expand Down
Loading