Skip to content
Open
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: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ jobs:
strategy:
matrix:
os: [ ubuntu-latest, windows-latest ]
systemLinqAsync: [ '5.0.0', '6.0.3' ]
systemLinqAsyncEnumerable: [ '10.0.6', '10.0.14' ]
env:
SystemLinqAsyncVersion: ${{matrix.systemLinqAsync}}
SystemLinqAsyncEnumerableVersion: ${{matrix.systemLinqAsyncEnumerable}}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
Expand Down
15 changes: 10 additions & 5 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@
<PackageVersion Include="xunit.extensibility.core" Version="[2.9.3, 2.10)" />
<PackageVersion Include="xunit.v3.assert" Version="[1.0.1, 5)" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="[1.0.1, 5)" />
<!-- Funcky.Async compiles against the 5.x operator vocabulary (SelectAwait, SelectAwaitWithCancellation, ...) and is
binary compatible with 6.x. System.Linq.Async 7 is a legacy shim over System.Linq.AsyncEnumerable with the .NET 10
operator shapes only, so it has none of the members Funcky.Async binds to; the upper bound keeps NuGet from resolving
it. The net10.0 asset of Funcky.Async only forwards to Funcky and has no System.Linq.Async dependency at all. -->
<PackageVersion Include="System.Linq.Async" Version="[5.0.0, 7)" />
<!-- Funcky.Async 2.x compiles against System.Linq.AsyncEnumerable, the package that is the IAsyncEnumerable LINQ of the
.NET 10 base class library, so Funcky's async sources compile identically against the base class library (net10.0
and newer) and against this package (older frameworks). It is deliberately not System.Linq.Async 7: that package is a
facade over System.Linq.AsyncEnumerable which also drags System.Interactive.Async in, whose Merge on a sequence of
sequences wins overload resolution over Funcky's sorted Merge (it has no optional comparer parameter) and would change
the behaviour of every consumer silently. A consumer that still references System.Linq.Async 5 or 6 gets a type clash
(CS0433) on System.Linq.AsyncEnumerable and upgrades to System.Linq.Async 7 or drops the reference; Funcky.Async 1.x
stays the line for projects that cannot. 10.0.6 is the first version whose dependencies carry no known vulnerability.
The net10.0 and net11.0 assets of Funcky.Async only forward to Funcky and have no dependency at all. -->
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="[10.0.6, 12)" />
</ItemGroup>
<ItemGroup Label="Build Dependencies">
<PackageVersion Include="PolySharp" Version="1.16.0" />
Expand Down
31 changes: 17 additions & 14 deletions Documentation/src/async.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,19 @@ The asynchronous APIs have two homes, and which one you use depends only on your
is in the base class library since .NET 10, so Funcky can build on it without an extra dependency. You need no second
package.
* **Older frameworks** (`netstandard2.0` up to .NET 9): they are in the `Funcky.Async` package, which compiles the same
sources against [System.Linq.Async](https://www.nuget.org/packages/System.Linq.Async) (versions 5 and 6).
sources against [System.Linq.AsyncEnumerable](https://www.nuget.org/packages/System.Linq.AsyncEnumerable), the very
library that is part of .NET 10, so the operators have the same shapes on both sides. A project that still references
[System.Linq.Async](https://www.nuget.org/packages/System.Linq.Async) 5 or 6 gets a type clash (CS0433) on
`System.Linq.AsyncEnumerable`; upgrade that reference to System.Linq.Async 7, which forwards to the same package, or
drop it. Funcky.Async 1.x is the line for projects that are stuck on System.Linq.Async 5 or 6; it receives no new
features.

The `net10.0` and `net11.0` assets of `Funcky.Async` contain nothing but type forwards to `Funcky`. A project that referenced
`Funcky.Async` on .NET 8 and then retargets to .NET 10 therefore keeps the package reference working, and can drop
it whenever convenient. The forwards do not make the two sides source compatible, though: where the base class
library already provides an operator, Funcky defers to it, and two of them differ from the `Funcky.Async` shape.

* `AdjacentGroupBy` returns `IAsyncEnumerable<IAsyncGrouping<TKey, T>>` in `Funcky.Async`, since System.Linq.Async
has no synchronous groupings, and `IAsyncEnumerable<IGrouping<TKey, T>>` on .NET 10. Code that treats a group as a
sequence, `await grouping.CountAsync()` for example, becomes `grouping.Count()` after retargeting.
* `Chunk(size)` without a result selector is the base class library's on .NET 10 and yields `T[]` instead of
`IReadOnlyList<T>`. The overloads with a result selector are Funcky's on both sides and keep `IReadOnlyList<T>`.
it whenever convenient. Since Funcky.Async 2.0.0 the two sides are also source compatible: `AdjacentGroupBy` returns
`IAsyncEnumerable<IGrouping<TKey, T>>` everywhere, and `Chunk(size)` without a result selector is the one of
`System.Linq.AsyncEnumerable` everywhere (it yields `T[]`; the overloads with a result selector are Funcky's and keep
`IReadOnlyList<T>`).

Namespaces and type names are otherwise the same on both sides: the extension
methods are in `Funcky.Extensions.AsyncEnumerableExtensions`, the constructors in `Funcky.AsyncSequence`, retrying in
Expand Down Expand Up @@ -131,7 +132,8 @@ Before 3.8.0 Funcky used the vocabulary of System.Linq.Async, which has three na
synchronous delegate, `InspectAwait` for an asynchronous one, and `InspectAwaitWithCancellation` for an asynchronous one
that receives the token. The terminal aggregations followed with `FirstOrNoneAwaitAsync` and
`FirstOrNoneAwaitWithCancellationAsync`. Since 3.8.0 these overloads are `[Obsolete]`, they stay a warning (CS0618)
for the whole 3.x line, and they are removed in Funcky 4.0.0 ([#981](https://github.com/polyadic/funcky/issues/981)).
for the whole 3.x line, and Funcky 4.0.0 removed them ([#981](https://github.com/polyadic/funcky/issues/981)): a call
of an old name is now the compile error CS1061, "no accessible extension method".

The mapping is mechanical:

Expand All @@ -147,10 +149,11 @@ its own replacement.

### Apply the code fix solution-wide

Do not do this by hand. [Funcky.Analyzers](./analyzer-rules/analyzer-rules.md) 2.1.0 ships a code fix for CS0618 on
these methods, with fix-all support. In the IDE, open the lightbulb on any of the warnings, choose the fix, and apply it
to the **solution** (the fix-all scope selector offers document, project and solution). One pass migrates every call
site. The fix:
Do not do this by hand. [Funcky.Analyzers](./analyzer-rules/analyzer-rules.md) ships a code fix for these methods,
with fix-all support: on 3.8.0 it fixes the obsoletion warning CS0618, and since Funcky.Analyzers 3.0.0 it also fixes
the compile error CS1061 that the removed names leave behind on 4.0.0, so you can upgrade first and migrate second.
In the IDE, open the lightbulb on any of the diagnostics, choose the fix, and apply it to the **solution** (the fix-all
scope selector offers document, project and solution). One pass migrates every call site. The fix:

* renames `…AwaitWithCancellation` and `…AwaitWithCancellationAsync` calls and leaves the delegate alone;
* renames `…Await` and `…AwaitAsync` calls and appends the discarded token parameter to every lambda argument,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@
namespace Funcky.Analyzers;

/// <summary>
/// Offers a fix for the obsoletion warning CS0618 on the <c>…Await</c> and <c>…AwaitWithCancellation</c> operators of
/// <c>AsyncEnumerableExtensions</c>, which the single-name overloads with a <c>(…, cancellationToken)</c> delegate replace:
/// <c>xs.FooAwaitWithCancellation((x, ct) => …)</c> becomes <c>xs.Foo((x, ct) => …)</c>, and <c>xs.FooAwait(x => …)</c>
/// becomes <c>xs.Foo((x, _) => …)</c>, with the ignored token parameter added to every delegate argument.
/// The terminal <c>…AwaitAsync</c> and <c>…AwaitWithCancellationAsync</c> aggregations map to <c>…Async</c> the same way.
/// Replaces a call of the <c>…Await</c> and <c>…AwaitWithCancellation</c> operators of <c>AsyncEnumerableExtensions</c>
/// with the single-name overload that takes a <c>(…, cancellationToken)</c> delegate: <c>xs.FooAwaitWithCancellation((x, ct) => …)</c>
/// becomes <c>xs.Foo((x, ct) => …)</c>, and <c>xs.FooAwait(x => …)</c> becomes <c>xs.Foo((x, _) => …)</c>, with the ignored
/// token parameter added to every delegate argument. The terminal <c>…AwaitAsync</c> and <c>…AwaitWithCancellationAsync</c>
/// aggregations map to <c>…Async</c> the same way. The fix is offered on the obsoletion warning CS0618 (Funcky 3.8.0) and on
/// the compile error CS1061 that a call of a removed operator reports (Funcky 4.0.0), so a project can upgrade first and
/// rewrite after. Without a bound method the fix recognizes the call by its name, by the receiver being one the replacement
/// accepts, and tells the delegate arguments apart by their syntax and types.
/// </summary>
[Shared]
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AsyncEnumerableAwaitOperatorCodeFix))]
Expand All @@ -26,6 +29,9 @@ public sealed class AsyncEnumerableAwaitOperatorCodeFix : CodeFixProvider
/// <summary>The compiler diagnostic reported on the use of a member marked <c>[Obsolete]</c> with a message.</summary>
private const string ObsoleteMemberDiagnosticId = "CS0618";

/// <summary>The compiler diagnostic reported on a member or extension method a type does not have, which a call of a removed operator is.</summary>
private const string MissingMemberDiagnosticId = "CS1061";

private const string AwaitSuffix = "Await";

private const string AwaitWithCancellationSuffix = "AwaitWithCancellation";
Expand All @@ -36,7 +42,7 @@ public sealed class AsyncEnumerableAwaitOperatorCodeFix : CodeFixProvider

private static readonly string[] CandidateParameterNames = ["x", "y", "z", "w"];

public override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(ObsoleteMemberDiagnosticId);
public override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(ObsoleteMemberDiagnosticId, MissingMemberDiagnosticId);

public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

Expand All @@ -52,22 +58,60 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)

foreach (var diagnostic in context.Diagnostics)
{
// The warning spans the whole call; one with a smaller span belongs to an obsolete member inside an argument.
if (root.FindInvocationExpression(diagnostic.Location.SourceSpan) is { } invocation
&& invocation.Span == diagnostic.Location.SourceSpan
&& GetMethodName(invocation) is { } name
&& semanticModel.GetOperation(invocation, context.CancellationToken) is IInvocationOperation { TargetMethod: var method } operation
&& IsDeclaredIn(method, extensionsType)
&& Replacement.TryCreate(method.Name) is { } replacement
&& HasMember(extensionsType, replacement.Name)
&& DelegateArguments(operation, invocation) is { } delegateArguments
&& (!replacement.AddsTokenParameter || delegateArguments.All(argument => IsRewritable(argument.Syntax))))
if (FindCall(root, semanticModel, diagnostic, extensionsType, context.CancellationToken) is { } call
&& (!call.Replacement.AddsTokenParameter || call.DelegateArguments.All(argument => IsRewritable(argument.Syntax))))
{
context.RegisterCodeFix(new RenameOperatorCodeFixAction(context.Document, invocation, name, replacement, delegateArguments), diagnostic);
context.RegisterCodeFix(new RenameOperatorCodeFixAction(context.Document, call.Invocation, call.Name, call.Replacement, call.DelegateArguments), diagnostic);
}
}
}

private static ObsoleteCall? FindCall(SyntaxNode root, SemanticModel semanticModel, Diagnostic diagnostic, INamedTypeSymbol extensionsType, CancellationToken cancellationToken)
=> diagnostic.Id switch
{
ObsoleteMemberDiagnosticId => FindObsoleteCall(root, semanticModel, diagnostic, extensionsType, cancellationToken),
MissingMemberDiagnosticId => FindRemovedCall(root, semanticModel, diagnostic, extensionsType, cancellationToken),
_ => null,
};

// The warning spans the whole call; one with a smaller span belongs to an obsolete member inside an argument.
private static ObsoleteCall? FindObsoleteCall(SyntaxNode root, SemanticModel semanticModel, Diagnostic diagnostic, INamedTypeSymbol extensionsType, CancellationToken cancellationToken)
=> root.FindInvocationExpression(diagnostic.Location.SourceSpan) is { } invocation
&& invocation.Span == diagnostic.Location.SourceSpan
&& GetMethodName(invocation) is { } name
&& semanticModel.GetOperation(invocation, cancellationToken) is IInvocationOperation { TargetMethod: var method } operation
&& IsDeclaredIn(method, extensionsType)
&& Replacement.TryCreate(method.Name) is { } replacement
&& HasMember(extensionsType, replacement.Name)
&& DelegateArguments(operation, invocation) is { } delegateArguments
? new ObsoleteCall(invocation, name, replacement, delegateArguments)
: null;

// The error spans the member name. Nothing binds the name to Funcky any more, so the call counts when the receiver is a type the
// replacement accepts and no argument is named (the delegates are found by position).
private static ObsoleteCall? FindRemovedCall(SyntaxNode root, SemanticModel semanticModel, Diagnostic diagnostic, INamedTypeSymbol extensionsType, CancellationToken cancellationToken)
=> root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true) is SimpleNameSyntax { Parent: MemberAccessExpressionSyntax { Expression: var receiver, Parent: InvocationExpressionSyntax invocation } memberAccess } name
&& memberAccess.Name == name
&& Replacement.TryCreate(name.Identifier.ValueText) is { } replacement
&& semanticModel.GetTypeInfo(receiver, cancellationToken).Type is { TypeKind: not TypeKind.Error } receiverType
&& AcceptsReceiver(extensionsType, replacement.Name, receiverType)
&& invocation.ArgumentList.Arguments.All(argument => argument.NameColon is null)
&& DelegateArguments(invocation, semanticModel, cancellationToken) is { } delegateArguments
? new ObsoleteCall(invocation, name, replacement, delegateArguments)
: null;

// An extension member is lowered to a static method with the receiver as its first parameter, like a classic extension method.
private static bool AcceptsReceiver(INamedTypeSymbol extensionsType, string name, ITypeSymbol receiverType)
=> extensionsType.GetMembers(name)
.OfType<IMethodSymbol>()
.Any(method => method is { IsStatic: true, Parameters: [{ Type: var parameterType }, ..] } && IsOrImplements(receiverType, parameterType));

private static bool IsOrImplements(ITypeSymbol type, ITypeSymbol definition)
=> HasSameDefinition(type, definition) || type.AllInterfaces.Any(implemented => HasSameDefinition(implemented, definition));

private static bool HasSameDefinition(ITypeSymbol type, ITypeSymbol other)
=> SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, other.OriginalDefinition);

private static SimpleNameSyntax? GetMethodName(InvocationExpressionSyntax invocation)
=> invocation.Expression switch
{
Expand Down Expand Up @@ -107,6 +151,59 @@ private static bool HasMember(INamedTypeSymbol type, string name)
return builder.ToImmutable();
}

/// <summary>The delegate arguments of an unbound call, told apart by their syntax and types; <see langword="null"/> when an argument
/// cannot be classified.</summary>
private static ImmutableArray<DelegateArgument>? DelegateArguments(InvocationExpressionSyntax invocation, SemanticModel semanticModel, CancellationToken cancellationToken)
{
var builder = ImmutableArray.CreateBuilder<DelegateArgument>();

foreach (var argument in invocation.ArgumentList.Arguments)
{
switch (DelegateArity(argument, semanticModel, cancellationToken))
{
case null:
return null;
case { } arity and >= 0:
builder.Add(new DelegateArgument(argument, arity));
break;
}
}

return builder.ToImmutable();
}

// The arity of a delegate argument, -1 for an argument of another type, null when neither can be told.
private static int? DelegateArity(ArgumentSyntax argument, SemanticModel semanticModel, CancellationToken cancellationToken)
{
var expression = Unparenthesize(argument.Expression);

return expression switch
{
SimpleLambdaExpressionSyntax => 1,
ParenthesizedLambdaExpressionSyntax lambda => lambda.ParameterList.Parameters.Count,
AnonymousMethodExpressionSyntax anonymousMethod => anonymousMethod.ParameterList?.Parameters.Count ?? 0,
_ => semanticModel.GetTypeInfo(expression, cancellationToken).Type switch
{
INamedTypeSymbol { TypeKind: TypeKind.Delegate, DelegateInvokeMethod: { } invoke } => invoke.Parameters.Length,
{ TypeKind: not TypeKind.Error } => -1,
_ => MethodGroupArity(expression, semanticModel, cancellationToken),
},
};
}

// A method group has no type of its own; its overloads agree on the arity in the cases the fix cares about.
private static int? MethodGroupArity(ExpressionSyntax expression, SemanticModel semanticModel, CancellationToken cancellationToken)
{
var symbolInfo = semanticModel.GetSymbolInfo(expression, cancellationToken);
var arities = (symbolInfo.Symbol is null ? symbolInfo.CandidateSymbols : ImmutableArray.Create(symbolInfo.Symbol))
.OfType<IMethodSymbol>()
.Select(method => method.Parameters.Length)
.Distinct()
.ToArray();

return arities is [var arity] ? arity : null;
}

// The operation's syntax is the argument, or the bare expression when parentheses surround it, which the operation tree skips.
private static ArgumentSyntax? ArgumentSyntax(IArgumentOperation argument, InvocationExpressionSyntax invocation)
=> argument.Syntax.FirstAncestorOrSelf<ArgumentSyntax>() is { } syntax && syntax.Parent == invocation.ArgumentList
Expand Down Expand Up @@ -144,6 +241,9 @@ private static ImmutableHashSet<string> IdentifiersIn(SyntaxNode node)

private sealed record DelegateArgument(ArgumentSyntax Syntax, int Arity);

/// <summary>A call of an old operator name and what to turn it into.</summary>
private sealed record ObsoleteCall(InvocationExpressionSyntax Invocation, SimpleNameSyntax Name, Replacement Replacement, ImmutableArray<DelegateArgument> DelegateArguments);

private sealed record Replacement(string Name, bool AddsTokenParameter)
{
// Lazy operators: FooAwait → Foo, FooAwaitWithCancellation → Foo. Terminal aggregations: FooAwaitAsync → FooAsync,
Expand Down
Loading
Loading