From 034b2667d06bf5751b7aa7979340e3eca23ca7e5 Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Tue, 28 Jul 2026 06:36:24 -0400 Subject: [PATCH 1/2] Cover the whitespace left in a rewritten argument list Recorded as the generator handles it today, so that the fix shows what changes. An extension call whose arguments were written across several lines, with a cancellation token last and a trailing ConfigureAwait, comes out as: global::Helpers.StreamExtensions.WriteTo(stream, destination, 4096, progress: progress) ; The receiver displaces the first argument without taking over the indentation which placed it at the start of a line, and the whitespace which separated the call from the dropped ConfigureAwait is left in front of the semicolon. This is the shape SharpCompress is formatted in throughout, so every method generated for it reads this way. Generated with Claude Code --- tests/Generator.Tests/ExtensionMethodTests.cs | 47 +++++++++++++++++++ ....StreamCallers.ext.CopyAsync.g.verified.cs | 21 +++++++++ 2 files changed, 68 insertions(+) create mode 100644 tests/Generator.Tests/Snapshots/ExtensionMethodTests.CSharp_14_ExtensionKeepsRemainingArgumentsOnTheirLines#Callers.StreamCallers.ext.CopyAsync.g.verified.cs 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..2c2d341 --- /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) ; + } + } +} From 773b68014ced94a32151a8bb99c5a1f2e7620ec2 Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Tue, 28 Jul 2026 06:38:04 -0400 Subject: [PATCH 2/2] Space a rewritten argument list the way it was written Three seams left whitespace behind which described a shape the rewrite had already changed: Unwrapping an extension rebuilt the argument list with fresh parentheses, losing the break which followed the opening one and the indentation which preceded the closing one. The original parentheses are kept instead. What trailed the closing one is still dropped, since it described how the call sat in whatever contained it, which unwrapping changes. The argument the receiver displaces is no longer the first thing on its line, so indentation written to place it at the start of one only leaves a gap after the comma, and is dropped. Indentation which still follows a break is left. Dropping a trailing ConfigureAwait left whatever separated the call from the dot in front of whichever token came next, typically the semicolon. When that is only whitespace it goes with the call. Anything else - a comment, a directive - was written about the expression which remains, so it is kept. global::Helpers.StreamExtensions.WriteTo(stream, destination, 4096, progress: progress); The break which belongs before the first argument is still missing: it is lost somewhere upstream of the unwrapping, which is left for zompinc/sync-method-generator#152. Generated with Claude Code --- .../AsyncToSyncRewriter.cs | 47 +++++++++++++++++-- ....StreamCallers.ext.CopyAsync.g.verified.cs | 4 +- ...mCallers.ext.DrainTwiceAsync.g.verified.cs | 2 +- 3 files changed, 45 insertions(+), 8 deletions(-) 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/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 index 2c2d341..a006646 100644 --- 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 @@ -13,9 +13,9 @@ public void Copy( global::System.IO.Stream destination, global::System.IProgress? progress = null ) => - global::Helpers.StreamExtensions.WriteTo(stream, destination, + global::Helpers.StreamExtensions.WriteTo(stream, destination, 4096, - progress: progress) ; + 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); } } }