diff --git a/src/Zomp.SyncMethodGenerator/AsyncToSyncRewriter.cs b/src/Zomp.SyncMethodGenerator/AsyncToSyncRewriter.cs index 6c0a2b2..33ab7f7 100644 --- a/src/Zomp.SyncMethodGenerator/AsyncToSyncRewriter.cs +++ b/src/Zomp.SyncMethodGenerator/AsyncToSyncRewriter.cs @@ -1484,11 +1484,28 @@ private static string ReplaceWithSpan(ISymbol symbol) => Regex.Replace(symbol.Name, Memory, Span); /// - /// Drops the invoked member and keeps the expression in front of the dot, - /// preserving the trivia surrounding the dot. + /// Drops the invoked member and keeps the expression in front of the dot. /// + /// + /// Whatever separated the expression from the dot described the shape of a call which is no + /// longer there. When that is only whitespace - a line break before a chained + /// ConfigureAwait, say - it goes with the call, rather than being left in front of + /// whichever token now follows. Anything else was written by a person about the expression + /// which remains, so all of it is kept, including the whitespace which spaces it. + /// + /// The member access to drop the member from. + /// The expression in front of the dot. private static ExpressionSyntax KeepExpressionBeforeDot(MemberAccessExpressionSyntax memberAccess) - => memberAccess.Expression.WithTrailingTrivia(TriviaList([.. memberAccess.Expression.GetTrailingTrivia(), .. memberAccess.OperatorToken.LeadingTrivia])); + { + var surrounding = TriviaList([.. memberAccess.Expression.GetTrailingTrivia(), .. memberAccess.OperatorToken.LeadingTrivia]); + + var isOnlySpacing = surrounding.All(static t + => t.IsKind(SyntaxKind.WhitespaceTrivia) || t.IsKind(SyntaxKind.EndOfLineTrivia)); + + return isOnlySpacing + ? memberAccess.Expression.WithoutTrailingTrivia() + : memberAccess.Expression.WithTrailingTrivia(surrounding); + } private static string MakeType(ISymbol symbol) => symbol switch @@ -2011,7 +2028,19 @@ private InvocationExpressionSyntax UnwrapExtension(InvocationExpressionSyntax ie // line break in a chained call - would otherwise land between it and the comma which // now follows. var @as = Argument(expression.WithoutTrivia()); - var newList = SeparatedList([@as, .. arguments], newSeparators); + + // The argument the receiver displaces is no longer the first thing on its line, so + // indentation which was written to place it at the start of one only leaves a gap after + // the comma. Indentation which still follows a line break is left alone. + List newArguments = [.. arguments]; + if (newArguments is [var displaced, ..] + && displaced.GetLeadingTrivia() is { Count: > 0 } leading + && leading.All(static t => t.IsKind(SyntaxKind.WhitespaceTrivia))) + { + newArguments[0] = displaced.WithoutLeadingTrivia(); + } + + var newList = SeparatedList([@as, .. newArguments], newSeparators); var newName = reducedFrom.Name; newName = changeMemoryToSpan ? ReplaceWithSpan(reducedFrom) : RemoveAsync(newName); @@ -2038,9 +2067,17 @@ private InvocationExpressionSyntax UnwrapExtension(InvocationExpressionSyntax ie }) .WithLeadingTrivia(expression.GetLeadingTrivia()); + // Keeping the original parentheses keeps the shape of the list between them: the break + // which follows the opening one and the indentation which precedes the closing one, in a + // list written across several lines. What trails the closing one described how the call + // sat in whatever contained it, which unwrapping has just changed, so it is dropped. + var argumentList = ies.ArgumentList + .WithArguments(newList) + .WithCloseParenToken(ies.ArgumentList.CloseParenToken.WithTrailingTrivia()); + return ies .WithExpression(es) - .WithArgumentList(ArgumentList(newList)); + .WithArgumentList(argumentList); } private ExpressionSyntax AppendSpan(ExpressionSyntax @base) diff --git a/tests/Generator.Tests/ExtensionMethodTests.cs b/tests/Generator.Tests/ExtensionMethodTests.cs index d8420b3..913011b 100644 --- a/tests/Generator.Tests/ExtensionMethodTests.cs +++ b/tests/Generator.Tests/ExtensionMethodTests.cs @@ -253,4 +253,51 @@ await stream } """.Verify(sourceType: SourceType.Full); #endif + +#if NET8_0_OR_GREATER + [Fact] + public Task CSharp_14_ExtensionKeepsRemainingArgumentsOnTheirLines() => """ +namespace Helpers +{ + internal static partial class StreamExtensions + { + extension(Stream stream) + { + internal async Task WriteToAsync(Stream destination, int size, IProgress? progress = null, CancellationToken cancellationToken = default) + => await stream.FlushAsync(cancellationToken); + + internal void WriteTo(Stream destination, int size, IProgress? progress = null) + { + } + } + } +} + +namespace Callers +{ + using Helpers; + + public static partial class StreamCallers + { + extension(Stream stream) + { + [Zomp.SyncMethodGenerator.CreateSyncVersion(PreserveProgress = true)] + public async Task CopyAsync( + Stream destination, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) => + await stream + .WriteToAsync( + destination, + 4096, + progress: progress, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + } + } +} +""".Verify(sourceType: SourceType.Full); +#endif } diff --git a/tests/Generator.Tests/Snapshots/ExtensionMethodTests.CSharp_14_ExtensionKeepsRemainingArgumentsOnTheirLines#Callers.StreamCallers.ext.CopyAsync.g.verified.cs b/tests/Generator.Tests/Snapshots/ExtensionMethodTests.CSharp_14_ExtensionKeepsRemainingArgumentsOnTheirLines#Callers.StreamCallers.ext.CopyAsync.g.verified.cs new file mode 100644 index 0000000..a006646 --- /dev/null +++ b/tests/Generator.Tests/Snapshots/ExtensionMethodTests.CSharp_14_ExtensionKeepsRemainingArgumentsOnTheirLines#Callers.StreamCallers.ext.CopyAsync.g.verified.cs @@ -0,0 +1,21 @@ +//HintName: Callers.StreamCallers.ext.CopyAsync.g.cs +// +#nullable enable +namespace Callers +{ + using Helpers; + + public static partial class StreamCallers + { + extension(global::System.IO.Stream stream) + { + public void Copy( + global::System.IO.Stream destination, + global::System.IProgress? progress = null + ) => + global::Helpers.StreamExtensions.WriteTo(stream, destination, + 4096, + progress: progress); + } + } +} diff --git a/tests/Generator.Tests/Snapshots/ExtensionMethodTests.CSharp_14_ExtensionUnwrapsOntoOneLine#Callers.StreamCallers.ext.DrainTwiceAsync.g.verified.cs b/tests/Generator.Tests/Snapshots/ExtensionMethodTests.CSharp_14_ExtensionUnwrapsOntoOneLine#Callers.StreamCallers.ext.DrainTwiceAsync.g.verified.cs index 1b475ad..9d45d3f 100644 --- a/tests/Generator.Tests/Snapshots/ExtensionMethodTests.CSharp_14_ExtensionUnwrapsOntoOneLine#Callers.StreamCallers.ext.DrainTwiceAsync.g.verified.cs +++ b/tests/Generator.Tests/Snapshots/ExtensionMethodTests.CSharp_14_ExtensionUnwrapsOntoOneLine#Callers.StreamCallers.ext.DrainTwiceAsync.g.verified.cs @@ -10,7 +10,7 @@ public static partial class StreamCallers extension(global::System.IO.Stream stream) { public void DrainTwice() => - global::Helpers.StreamExtensions.Drain(stream, 1024) ; + global::Helpers.StreamExtensions.Drain(stream, 1024); } } }