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
3 changes: 2 additions & 1 deletion ANcpLua.Roslyn.Utilities.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@
<Folder Name="/examples/">
<Project Path="src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer.csproj"/>
<Project Path="src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationCodeFixes/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationCodeFixes.csproj"/>
<Project Path="src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared.csproj"/>
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ANcpLua.Roslyn.Utilities.CrefRegression.Tests/ANcpLua.Roslyn.Utilities.CrefRegression.Tests.csproj"/>
<Project Path="tests/ANcpLua.Roslyn.Utilities.Testing.Tests/ANcpLua.Roslyn.Utilities.Testing.Tests.csproj"/>
<Project Path="tests/ANcpLua.Roslyn.Utilities.ExtensibleEnumMirror.Tests/ANcpLua.Roslyn.Utilities.ExtensibleEnumMirror.Tests.csproj"/>
<Project Path="tests/ANcpLua.Roslyn.Utilities.DiscriminatedUnion.Tests/ANcpLua.Roslyn.Utilities.DiscriminatedUnion.Tests.csproj"/>
</Folder>
</Solution>
</Solution>
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@

<ItemGroup>
<ProjectReference Include="..\ANcpLua.Roslyn.Utilities\ANcpLua.Roslyn.Utilities.csproj" />
<ProjectReference Include="..\ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared\ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Linq;
using ANcpLua.Roslyn.Utilities;
using ANcpLua.Roslyn.Utilities.Analyzers;
using ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
Expand All @@ -12,7 +13,7 @@ namespace ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class MissingCancellationTokenAnalyzer : DiagnosticAnalyzerBase
{
public const string DiagnosticId = "ANCP0001";
public const string DiagnosticId = MissingCancellationTokenContract.DiagnosticId;

private static readonly DiagnosticDescriptor s_rule = new(
id: DiagnosticId,
Expand Down Expand Up @@ -61,9 +62,9 @@ private static void AnalyzeInvocation(OperationAnalysisContext context)
return;

var properties = ImmutableDictionary<string, string?>.Empty
.Add(MissingCancellationTokenDiagnosticProperties.ParameterName, parameter.Name)
.Add(MissingCancellationTokenContract.ParameterNameProperty, parameter.Name)
.Add(
MissingCancellationTokenDiagnosticProperties.ParameterIndex,
MissingCancellationTokenContract.ParameterIndexProperty,
parameter.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture));

context.ReportDiagnostic(
Expand All @@ -80,24 +81,6 @@ private static bool IsXunitTestMethod(IMethodSymbol method)
|| method.HasAttribute("Xunit.TheoryAttribute");
}

private static bool IsDefaultLike(IOperation operation, ITypeSymbol cancellationTokenType)
{
operation = operation.UnwrapAllConversions().UnwrapParenthesized();

if (operation.Syntax.IsKind(SyntaxKind.DefaultExpression)
|| operation.Syntax.IsKind(SyntaxKind.DefaultLiteralExpression))
return true;

return operation is IPropertyReferenceOperation
{
Property: { Name: "None", ContainingType: { } containingType }
}
&& containingType.IsEqualTo(cancellationTokenType);
}
}

internal static class MissingCancellationTokenDiagnosticProperties
{
public const string ParameterName = nameof(ParameterName);
public const string ParameterIndex = nameof(ParameterIndex);
private static bool IsDefaultLike(IOperation operation, ITypeSymbol cancellationTokenType) =>
MissingCancellationTokenContract.IsDefaultCancellationTokenArgument(operation, cancellationTokenType);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@

<ItemGroup>
<ProjectReference Include="..\ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer\ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer.csproj" />
<ProjectReference Include="..\ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared\ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Threading;
using System.Threading.Tasks;
using ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer;
using ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
Expand Down Expand Up @@ -48,15 +49,15 @@ private static async Task<Document> ApplyAsync(
Diagnostic diagnostic,
CancellationToken cancellationToken)
{
if (!diagnostic.Properties.TryGetValue(DiagnosticPropertyNames.ParameterName, out var parameterName)
if (!diagnostic.Properties.TryGetValue(MissingCancellationTokenContract.ParameterNameProperty, out var parameterName)
|| string.IsNullOrWhiteSpace(parameterName)
|| !diagnostic.Properties.TryGetValue(DiagnosticPropertyNames.ParameterIndex, out var parameterIndexText)
|| !diagnostic.Properties.TryGetValue(MissingCancellationTokenContract.ParameterIndexProperty, out var parameterIndexText)
|| !int.TryParse(parameterIndexText, out var parameterIndex))
return document;

var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false);
var arguments = invocation.ArgumentList.Arguments.ToList();
var tokenExpression = ParseExpression("TestContext.Current.CancellationToken");
var tokenExpression = MissingCancellationTokenContract.CreateReplacementTokenExpression();
var safeParameterName = parameterName ?? string.Empty;

var argumentIndex = arguments.FindIndex(argument =>
Expand Down Expand Up @@ -87,9 +88,4 @@ private static async Task<Document> ApplyAsync(
return editor.GetChangedDocument();
}

private static class DiagnosticPropertyNames
{
public const string ParameterName = nameof(ParameterName);
public const string ParameterIndex = nameof(ParameterIndex);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<RootNamespace>ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared</RootNamespace>
<IsPackable>false</IsPackable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\ANcpLua.Roslyn.Utilities\ANcpLua.Roslyn.Utilities.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Operations;
using ANcpLua.Roslyn.Utilities;

namespace ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared;

public static class MissingCancellationTokenContract
{
public const string DiagnosticId = "ANCP0001";
public const string ParameterNameProperty = "ParameterName";
public const string ParameterIndexProperty = "ParameterIndex";
public const string ReplacementExpression = "TestContext.Current.CancellationToken";

public static bool IsDefaultCancellationTokenArgument(IOperation operation, ITypeSymbol cancellationTokenType)
{
if (operation is null)
return false;

operation = operation.UnwrapAllConversions().UnwrapParenthesized();

if (operation.Syntax.IsKind(SyntaxKind.DefaultExpression)
|| operation.Syntax.IsKind(SyntaxKind.DefaultLiteralExpression))
return true;

return operation is IPropertyReferenceOperation
{
Property: { Name: nameof(CancellationToken.None), ContainingType: { } containingType }
} && containingType.IsEqualTo(cancellationTokenType);
}

public static ExpressionSyntax CreateReplacementTokenExpression() =>
SyntaxFactory.ParseExpression(ReplacementExpression);

}
60 changes: 37 additions & 23 deletions src/ANcpLua.Roslyn.Utilities/IncrementalValuesProviderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -278,30 +278,37 @@ public static IncrementalValueProvider<TResult> SelectAndReportExceptions<TSourc
string id = "SRE001")
{
var outputWithErrors = source
.Select((value, cancellationToken) =>
.Select<TSource, (TResult? Value, GeneratorErrorInfo? Error)>((value, cancellationToken) =>
{
cancellationToken.ThrowIfCancellationRequested();

try
{
return (Value: selector(value, cancellationToken), Exception: null);
return (selector(value, cancellationToken), null);
}
catch (OperationCanceledException)
{
// Cancellation must propagate so Roslyn can abort the pipeline.
throw;
}
Comment on lines +289 to 293

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Only rethrow cancellation tied to Roslyn token

This helper now rethrows every OperationCanceledException, which means selectors that throw OCE for reasons unrelated to the pipeline token (for example, a nested API using its own cancellation token) will abort generation and skip the SRE001 diagnostic path. That breaks the method’s “report exceptions and continue” behavior for a real class of failures; it should only propagate when the Roslyn cancellationToken is actually canceled (e.g., catch filter on cancellationToken.IsCancellationRequested or matching token).

Useful? React with 👍 / 👎.

catch (Exception exception)
{
return (Value: default(TResult), Exception: (Exception?)exception);
return (default, GeneratorErrorInfo.From(exception));
}
});

initializationContext.RegisterSourceOutput(outputWithErrors,
(context, tuple) =>
{
if (tuple.Exception is not null)
context.ReportException(id, tuple.Exception);
if (tuple.Error is { } error)
context.ReportException(id, error);
});

return outputWithErrors
.Select(static (x, _) =>
x.Value ?? throw new InvalidOperationException("Unexpected null value in SelectAndReportExceptions"));
// The diagnostic registered above is the visible failure for the user. Downstream
// consumers receive the selector's value (or default(TResult) on failure) and decide
// how to react — record-struct sentinels like FileWithName.Empty, for instance, are
// already filtered by AddSource. Throwing here would crash the generator a second time.
return outputWithErrors.Select(static (x, _) => x.Value!);
}

/// <summary>
Expand Down Expand Up @@ -450,35 +457,36 @@ public static IncrementalValuesProvider<TResult> SelectAndReportExceptions<TSour
string id = "SRE001")
{
var outputWithErrors = source
.Select((value, cancellationToken) =>
.Select<TSource, (TResult? Value, GeneratorErrorInfo? Error)>((value, cancellationToken) =>
{
cancellationToken.ThrowIfCancellationRequested();

try
{
return (Value: selector(value, cancellationToken), Exception: null);
return (selector(value, cancellationToken), null);
}
catch (OperationCanceledException)
{
// Cancellation must propagate so Roslyn can abort the pipeline.
throw;
}
catch (Exception exception)
{
return (Value: default(TResult), Exception: (Exception?)exception);
return (default, GeneratorErrorInfo.From(exception));
}
});

initializationContext.RegisterSourceOutput(outputWithErrors
.Where(static x => x.Exception is not null),
.Where(static x => x.Error is not null),
(context, tuple) =>
{
context.ReportException(id,
tuple.Exception ??
throw new InvalidOperationException(
"Unexpected null exception in SelectAndReportExceptions"));
if (tuple.Error is { } error)
context.ReportException(id, error);
});

return outputWithErrors
.Where(static x => x.Exception is null)
.Select(static (x, _) => x.Value ??
throw new InvalidOperationException(
"Unexpected null value in SelectAndReportExceptions"));
.Where(static x => x.Error is null)
.Select(static (x, _) => x.Value!);
}

/// <summary>
Expand Down Expand Up @@ -519,22 +527,28 @@ public static IncrementalValuesProvider<TResult> SelectAndReportExceptions<TSour
comparer ??= EqualityComparer<TKey>.Default;
return source.Collect().SelectMany((values, _) =>
{
// Preserve key-insertion order so generator output is deterministic.
// Dictionary<,> enumeration order is not part of the contract (it happens
// to follow insertion in the current .NET runtime, but a future runtime
// is free to change that, and adding/removing entries can shift it today).
var map = new Dictionary<TKey, ImmutableArray<TElement>.Builder>(comparer);
var keys = new List<TKey>();
foreach (var value in values)
{
var key = keySelector(value);
if (!map.TryGetValue(key, out var builder))
{
builder = ImmutableArray.CreateBuilder<TElement>();
map.Add(key, builder);
keys.Add(key);
}

builder.Add(elementSelector(value));
}

var result = ImmutableArray.CreateBuilder<(TKey, EquatableArray<TElement>)>(map.Count);
foreach (var entry in map)
result.Add((entry.Key, entry.Value.ToImmutable().AsEquatableArray()));
var result = ImmutableArray.CreateBuilder<(TKey, EquatableArray<TElement>)>(keys.Count);
foreach (var key in keys)
result.Add((key, map[key].ToImmutable().AsEquatableArray()));
return result.MoveToImmutable();
});
}
Expand Down
52 changes: 52 additions & 0 deletions src/ANcpLua.Roslyn.Utilities/Models/GeneratorErrorInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) ANcpLua. All rights reserved.
// Licensed under the MIT License.

namespace ANcpLua.Roslyn.Utilities.Models;

/// <summary>
/// A value-equatable snapshot of an <see cref="Exception" /> safe to flow through the
/// incremental generator cache.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Exception" /> instances do not implement value equality, so storing one in
/// pipeline state forces every cache comparison to reference-equality and breaks
/// incremental caching across compilations. <see cref="GeneratorErrorInfo" /> captures the
/// minimum information needed to surface the failure as a diagnostic — type name, message,
/// and stack trace — as immutable strings, which the record-struct equality contract
/// compares by value.
/// </para>
/// </remarks>
/// <param name="TypeName">The exception's runtime type name (e.g. <c>System.InvalidOperationException</c>).</param>
/// <param name="Message">The exception's message, or empty if absent.</param>
/// <param name="StackTrace">The exception's stack trace, or empty if absent.</param>
#if ANCPLUA_ROSLYN_PUBLIC
public
#else
internal
#endif
readonly record struct GeneratorErrorInfo(string TypeName, string Message, string StackTrace)
{
/// <summary>
/// Captures an <see cref="Exception" /> as a value-equatable <see cref="GeneratorErrorInfo" />.
/// </summary>
public static GeneratorErrorInfo From(Exception exception)
{
if (exception is null) throw new ArgumentNullException(nameof(exception));
return new GeneratorErrorInfo(
exception.GetType().FullName ?? exception.GetType().Name,
exception.Message ?? string.Empty,
exception.StackTrace ?? string.Empty);
}

/// <summary>
/// Renders the captured error in <c>Type: Message\n StackTrace</c> form, mirroring
/// <see cref="Exception.ToString" />.
/// </summary>
public override string ToString()
{
return string.IsNullOrEmpty(StackTrace)
? $"{TypeName}: {Message}"
: $"{TypeName}: {Message}{Environment.NewLine}{StackTrace}";
}
}
32 changes: 32 additions & 0 deletions src/ANcpLua.Roslyn.Utilities/SourceProductionContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,38 @@ public static void ReportException(
context.ReportDiagnostic(exception.ToDiagnostic(id, prefix));
}

/// <summary>
/// Reports a captured <see cref="GeneratorErrorInfo" /> as an error diagnostic.
/// </summary>
/// <remarks>
/// Use this overload from incremental pipeline stages so the captured error can flow
/// through cache state by value rather than as a reference-equatable
/// <see cref="Exception" />.
/// </remarks>
/// <param name="context">The source production context to report the diagnostic to.</param>
/// <param name="id">The diagnostic ID (e.g., <c>"GEN001"</c>).</param>
/// <param name="error">The captured error to report.</param>
/// <param name="prefix">Optional prefix prepended to <paramref name="id" />.</param>
public static void ReportException(
this SourceProductionContext context,
string id,
GeneratorErrorInfo error,
string? prefix = null)
{
id = id ?? throw new ArgumentNullException(nameof(id));
if (prefix is not null) id = $"{prefix}{id}";

context.ReportDiagnostic(Diagnostic.Create(
new DiagnosticDescriptor(
id,
"Exception: ",
error.ToString(),
"Usage",
DiagnosticSeverity.Error,
true),
Location.None));
}

/// <summary>
/// Creates a <see cref="Diagnostic" /> from an exception.
/// </summary>
Expand Down
Loading
Loading