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);
}
}
}