From d43d9e6c211b82237db737c04424733c60048bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 16 May 2026 10:10:39 +0200 Subject: [PATCH 1/2] feat: migrate whole document in a single pass via custom FixAllProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous BatchFixer-based pipeline dropped per-diagnostic rewrites whose text changes overlapped on the using-directive line, and once the first using swap landed the analyzer no longer fired on remaining TestableIO bindings — leaving files partially migrated and uncompilable. Each per-pattern Apply method is split into a pure helper that operates on (CompilationUnitSyntax, SyntaxNode) without touching usings. A new SystemIOAbstractionsFixAllProvider annotates every diagnostic target, dispatches the pure helpers sequentially with annotation-relocation and re-acquired semantic models, then applies a single using-directive change at the end. The per-diagnostic CodeAction re-runs the analyzer to find sibling diagnostics in the same document and routes through the same provider, so one click in the IDE migrates the whole file (Mockolate-style). --- .../SystemIOAbstractionsCodeFixProvider.cs | 420 +++++++++++------- .../SystemIOAbstractionsFixAllProvider.cs | 176 ++++++++ ...actionsCodeFixProviderTests.FixAllTests.cs | 155 +++++++ 3 files changed, 591 insertions(+), 160 deletions(-) create mode 100644 Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsFixAllProvider.cs create mode 100644 Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.FixAllTests.cs diff --git a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs index b97dd89..5a07c1d 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs @@ -9,6 +9,7 @@ using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; namespace Testably.Abstractions.Migration.Analyzers; @@ -29,7 +30,7 @@ public override ImmutableArray FixableDiagnosticIds /// public override FixAllProvider? GetFixAllProvider() - => WellKnownFixAllProviders.BatchFixer; + => SystemIOAbstractionsFixAllProvider.Instance; /// public override async Task RegisterCodeFixesAsync(CodeFixContext context) @@ -119,7 +120,8 @@ private static void TryRegisterDefaultCtorFix(CodeFixContext context, Diagnostic context.RegisterCodeFix( CodeAction.Create( Resources.TestablyM001CodeFixTitle, - ct => RewriteUsingsAsync(context.Document, ct), + ct => ApplySinglePatternAsync(context.Document, diagnostic, + Patterns.MockFileSystemDefaultConstructor, ct), equivalenceKey: Patterns.MockFileSystemDefaultConstructor), diagnostic); } @@ -157,16 +159,16 @@ private static bool HasUnqualifiedImplicitTargetType(ImplicitObjectCreationExpre }; } - private static async Task RewriteUsingsAsync(Document document, CancellationToken cancellationToken) + /// + /// Pure rewriter for the default new MockFileSystem() constructor pattern. + /// The fix is using-directive-only; this helper just gates on the target node + /// shape and returns the compilation unit unchanged. The caller swaps the using. + /// + private static CompilationUnitSyntax? ApplyDefaultCtorPure(CompilationUnitSyntax cu, SyntaxNode target) { - SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - if (root is not CompilationUnitSyntax compilationUnit) - { - return document; - } - - compilationUnit = SwapToTestablyUsing(compilationUnit); - return document.WithSyntaxRoot(compilationUnit); + BaseObjectCreationExpressionSyntax? creation = + target.FirstAncestorOrSelf(); + return creation is not null && HasUnqualifiedMockFileSystemTypeName(creation) ? cu : null; } // ── Pattern: MockFileSystem.ctor(options) ──────────────────────────────── @@ -188,39 +190,29 @@ private static void TryRegisterOptionsCtorFix(CodeFixContext context, Diagnostic context.RegisterCodeFix( CodeAction.Create( Resources.TestablyM001CodeFixTitle, - ct => ApplyOptionsCtorRewriteAsync(context.Document, diagnostic, ct), + ct => ApplySinglePatternAsync(context.Document, diagnostic, + Patterns.MockFileSystemOptionsConstructor, ct), equivalenceKey: Patterns.MockFileSystemOptionsConstructor), diagnostic); } - private static async Task ApplyOptionsCtorRewriteAsync( - Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + private static CompilationUnitSyntax? ApplyOptionsCtorPure(CompilationUnitSyntax cu, SyntaxNode target) { - SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - if (root is not CompilationUnitSyntax compilationUnit) - { - return document; - } - - SyntaxNode? node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); - ObjectCreationExpressionSyntax? creation = node?.FirstAncestorOrSelf(); + ObjectCreationExpressionSyntax? creation = target.FirstAncestorOrSelf(); if (creation?.ArgumentList is not { Arguments.Count: 1, } argList) { - return document; + return null; } ArgumentListSyntax? newArgList = TryBuildTestablyOptionsArgList(argList.Arguments[0].Expression); if (newArgList is null) { - return document; + return null; } ObjectCreationExpressionSyntax newCreation = creation.WithArgumentList(newArgList.WithTriviaFrom(argList)); - compilationUnit = compilationUnit.ReplaceNode(creation, newCreation); - compilationUnit = SwapToTestablyUsing(compilationUnit); - - return document.WithSyntaxRoot(compilationUnit); + return cu.ReplaceNode(creation, newCreation); } private static ArgumentListSyntax? TryBuildTestablyOptionsArgList(ExpressionSyntax optionsExpression) @@ -339,7 +331,7 @@ private static async Task TryRegisterAccessorMethodFixAsync( context.RegisterCodeFix( CodeAction.Create( Resources.TestablyM001CodeFixTitle, - ct => ApplyAccessorMethodRewriteAsync(context.Document, diagnostic, pattern, ct), + ct => ApplySinglePatternAsync(context.Document, diagnostic, pattern, ct), equivalenceKey: pattern), diagnostic); } @@ -353,20 +345,13 @@ private static bool IsConcreteMockFileSystemReceiver(ExpressionSyntax receiver, == "System.IO.Abstractions.TestingHelpers"; } - private static async Task ApplyAccessorMethodRewriteAsync( - Document document, Diagnostic diagnostic, string pattern, CancellationToken cancellationToken) + private static CompilationUnitSyntax? ApplyAccessorMethodPure( + CompilationUnitSyntax cu, SyntaxNode target, string pattern) { - SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - if (root is not CompilationUnitSyntax compilationUnit) - { - return document; - } - - SyntaxNode? node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); - InvocationExpressionSyntax? invocation = node?.FirstAncestorOrSelf(); + InvocationExpressionSyntax? invocation = target.FirstAncestorOrSelf(); if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess) { - return document; + return null; } ExpressionSyntax newExpression = pattern switch @@ -385,11 +370,10 @@ private static async Task ApplyAccessorMethodRewriteAsync( if (ReferenceEquals(newExpression, invocation)) { - return document; + return null; } - compilationUnit = compilationUnit.ReplaceNode(invocation, newExpression.WithTriviaFrom(invocation)); - return document.WithSyntaxRoot(compilationUnit); + return cu.ReplaceNode(invocation, newExpression.WithTriviaFrom(invocation)); } private static InvocationExpressionSyntax BuildSubReceiverInvocation( @@ -473,32 +457,20 @@ private static async Task TryRegisterAddFileFixAsync( context.RegisterCodeFix( CodeAction.Create( Resources.TestablyM001CodeFixTitle, - ct => ApplyAddFileRewriteAsync(context.Document, diagnostic, ct), + ct => ApplySinglePatternAsync(context.Document, diagnostic, + Patterns.AccessorAddFile, ct), equivalenceKey: Patterns.AccessorAddFile), diagnostic); } - private static async Task ApplyAddFileRewriteAsync( - Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + private static CompilationUnitSyntax? ApplyAddFilePure( + CompilationUnitSyntax cu, SyntaxNode target, SemanticModel semanticModel, CancellationToken cancellationToken) { - SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - if (root is not CompilationUnitSyntax compilationUnit) - { - return document; - } - - SemanticModel? semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); - if (semanticModel is null) - { - return document; - } - - SyntaxNode? node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); - InvocationExpressionSyntax? invocation = node?.FirstAncestorOrSelf(); + InvocationExpressionSyntax? invocation = target.FirstAncestorOrSelf(); if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess || invocation.ArgumentList.Arguments.Count < 2) { - return document; + return null; } ArgumentSyntax pathArg = invocation.ArgumentList.Arguments[0]; @@ -506,7 +478,7 @@ private static async Task ApplyAddFileRewriteAsync( invocation.ArgumentList.Arguments[1].Expression, semanticModel, cancellationToken); if (shape is null) { - return document; + return null; } InvocationExpressionSyntax rewritten = BuildAddFileReplacement(memberAccess, pathArg, shape.Value); @@ -514,8 +486,7 @@ private static async Task ApplyAddFileRewriteAsync( // Without initializer properties, the rewrite is a 1:1 invocation swap. if (shape.Value.InitializerProperties.Count == 0) { - compilationUnit = compilationUnit.ReplaceNode(invocation, rewritten.WithTriviaFrom(invocation)); - return document.WithSyntaxRoot(compilationUnit); + return cu.ReplaceNode(invocation, rewritten.WithTriviaFrom(invocation)); } // With initializer properties, the expansion requires inserting follow-up @@ -525,7 +496,7 @@ private static async Task ApplyAddFileRewriteAsync( if (invocation.Parent is not ExpressionStatementSyntax originalStatement || originalStatement.Parent is not BlockSyntax block) { - return document; + return null; } (string indentation, string newline) = DetectIndentationAndNewline(originalStatement); @@ -552,8 +523,7 @@ private static async Task ApplyAddFileRewriteAsync( updatedStatements = updatedStatements.InsertRange(index + 1, followUps); BlockSyntax newBlock = block.WithStatements(updatedStatements); - compilationUnit = compilationUnit.ReplaceNode(block, newBlock); - return document.WithSyntaxRoot(compilationUnit); + return cu.ReplaceNode(block, newBlock); } private static InvocationExpressionSyntax BuildAddFileReplacement( @@ -776,41 +746,31 @@ private static async Task TryRegisterFilesCtorFixAsync( context.RegisterCodeFix( CodeAction.Create( Resources.TestablyM001CodeFixTitle, - ct => ApplyFilesCtorRewriteAsync(context.Document, diagnostic, pattern, ct), + ct => ApplySinglePatternAsync(context.Document, diagnostic, pattern, ct), equivalenceKey: pattern), diagnostic); } - private static async Task ApplyFilesCtorRewriteAsync( - Document document, Diagnostic diagnostic, string pattern, CancellationToken cancellationToken) + private static CompilationUnitSyntax? ApplyFilesCtorPure( + CompilationUnitSyntax cu, + SyntaxNode target, + SemanticModel semanticModel, + string pattern, + CancellationToken cancellationToken) { - SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - if (root is not CompilationUnitSyntax compilationUnit) - { - return document; - } - - SemanticModel? semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); - if (semanticModel is null) - { - return document; - } - - SyntaxNode? node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); - if (node is null - || !TryGetCreationInLocalDecl(node, out BaseObjectCreationExpressionSyntax? creation, + if (!TryGetCreationInLocalDecl(target, out BaseObjectCreationExpressionSyntax? creation, out ArgumentListSyntax? argList, out LocalDeclarationStatementSyntax? localDecl, out BlockSyntax? block)) { - return document; + return null; } List? entries = TryParseDictionaryEntries( argList!.Arguments[0].Expression, semanticModel, cancellationToken); if (entries is null) { - return document; + return null; } ArgumentListSyntax newArgList = SyntaxFactory.ArgumentList(); @@ -819,7 +779,7 @@ private static async Task ApplyFilesCtorRewriteAsync( ArgumentListSyntax? built = TryBuildSecondCtorArgList(argList.Arguments[1], pattern); if (built is null) { - return document; + return null; } newArgList = built; @@ -860,10 +820,7 @@ private static async Task ApplyFilesCtorRewriteAsync( updatedStatements = updatedStatements.InsertRange(index + 1, followUps); BlockSyntax newBlock = block.WithStatements(updatedStatements); - compilationUnit = compilationUnit.ReplaceNode(block, newBlock); - compilationUnit = SwapToTestablyUsing(compilationUnit); - - return document.WithSyntaxRoot(compilationUnit); + return cu.ReplaceNode(block, newBlock); } private static bool TryGetCreationInLocalDecl( @@ -1104,29 +1061,21 @@ private static void TryRegisterPropertyReadFix( context.RegisterCodeFix( CodeAction.Create( Resources.TestablyM001CodeFixTitle, - ct => ApplyPropertyReadRewriteAsync(context.Document, diagnostic, ct), + ct => ApplySinglePatternAsync(context.Document, diagnostic, + Patterns.MockFileDataPropertyRead, ct), equivalenceKey: Patterns.MockFileDataPropertyRead), diagnostic); } - private static async Task ApplyPropertyReadRewriteAsync( - Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + private static CompilationUnitSyntax? ApplyPropertyReadPure(CompilationUnitSyntax cu, SyntaxNode target) { - SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - if (root is not CompilationUnitSyntax compilationUnit) - { - return document; - } - - SyntaxNode? node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); - if (node is null - || !TryMatchOneShotGetFileRead(node, + if (!TryMatchOneShotGetFileRead(target, out MemberAccessExpressionSyntax? memberAccess, out ExpressionSyntax? receiver, out ArgumentSyntax? pathArg, out string? newMethod)) { - return document; + return null; } InvocationExpressionSyntax replacement = SyntaxFactory.InvocationExpression( @@ -1139,8 +1088,7 @@ private static async Task ApplyPropertyReadRewriteAsync( SyntaxFactory.IdentifierName(newMethod!)), SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(pathArg!.WithoutTrivia()))); - compilationUnit = compilationUnit.ReplaceNode(memberAccess!, replacement.WithTriviaFrom(memberAccess!)); - return document.WithSyntaxRoot(compilationUnit); + return cu.ReplaceNode(memberAccess!, replacement.WithTriviaFrom(memberAccess!)); } private static bool TryMatchOneShotGetFileRead( @@ -1216,23 +1164,15 @@ private static void TryRegisterPropertyWriteFix( context.RegisterCodeFix( CodeAction.Create( Resources.TestablyM001CodeFixTitle, - ct => ApplyPropertyWriteRewriteAsync(context.Document, diagnostic, ct), + ct => ApplySinglePatternAsync(context.Document, diagnostic, + Patterns.MockFileDataPropertyWrite, ct), equivalenceKey: Patterns.MockFileDataPropertyWrite), diagnostic); } - private static async Task ApplyPropertyWriteRewriteAsync( - Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + private static CompilationUnitSyntax? ApplyPropertyWritePure(CompilationUnitSyntax cu, SyntaxNode target) { - SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - if (root is not CompilationUnitSyntax compilationUnit) - { - return document; - } - - SyntaxNode? node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); - if (node is null - || !TryMatchOneShotGetFileWrite(node, + if (!TryMatchOneShotGetFileWrite(target, out _, out ExpressionStatementSyntax? statement, out ExpressionSyntax? receiver, @@ -1240,7 +1180,7 @@ private static async Task ApplyPropertyWriteRewriteAsync( out ExpressionSyntax? value, out string? newMethod)) { - return document; + return null; } // Build the new statement by parsing it from text. The resulting trivia is @@ -1253,8 +1193,7 @@ private static async Task ApplyPropertyWriteRewriteAsync( StatementSyntax parsed = SyntaxFactory.ParseStatement(text); StatementSyntax newStatement = parsed.WithTriviaFrom(statement!); - compilationUnit = compilationUnit.ReplaceNode(statement!, newStatement); - return document.WithSyntaxRoot(compilationUnit); + return cu.ReplaceNode(statement!, newStatement); } private static bool TryMatchOneShotGetFileWrite( @@ -1352,7 +1291,8 @@ private static async Task TryRegisterAddDriveFixAsync( context.RegisterCodeFix( CodeAction.Create( Resources.TestablyM001CodeFixTitle, - ct => ApplyAddDriveRewriteAsync(context.Document, diagnostic, ct), + ct => ApplySinglePatternAsync(context.Document, diagnostic, + Patterns.MockFileSystemAddDrive, ct), equivalenceKey: Patterns.MockFileSystemAddDrive), diagnostic); } @@ -1408,21 +1348,13 @@ private static bool IsUnqualifiedMockFileSystemTypeSyntax(TypeSyntax typeSyntax) _ => false, }; - private static async Task ApplyAddDriveRewriteAsync( - Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + private static CompilationUnitSyntax? ApplyAddDrivePure(CompilationUnitSyntax cu, SyntaxNode target) { - SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - if (root is not CompilationUnitSyntax compilationUnit) - { - return document; - } - - SyntaxNode? node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); - InvocationExpressionSyntax? invocation = node?.FirstAncestorOrSelf(); + InvocationExpressionSyntax? invocation = target.FirstAncestorOrSelf(); if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess || invocation.ArgumentList.Arguments.Count != 2) { - return document; + return null; } ArgumentSyntax driveNameArg = invocation.ArgumentList.Arguments[0]; @@ -1430,14 +1362,12 @@ private static async Task ApplyAddDriveRewriteAsync( if (!TryClassifyMockDriveDataInitializer(driveDataExpr, out List? assignments)) { - return document; + return null; } InvocationExpressionSyntax replacement = BuildWithDriveInvocation( memberAccess.Expression, driveNameArg, assignments); - compilationUnit = compilationUnit.ReplaceNode(invocation, replacement.WithTriviaFrom(invocation)); - compilationUnit = SwapToTestablyUsing(compilationUnit); - return document.WithSyntaxRoot(compilationUnit); + return cu.ReplaceNode(invocation, replacement.WithTriviaFrom(invocation)); } private static bool TryClassifyMockDriveDataInitializer( @@ -1625,32 +1555,20 @@ private static async Task TryRegisterAddFilesFromEmbeddedNamespaceFixAsync( context.RegisterCodeFix( CodeAction.Create( Resources.TestablyM001CodeFixTitle, - ct => ApplyAddFilesFromEmbeddedNamespaceRewriteAsync(context.Document, diagnostic, ct), + ct => ApplySinglePatternAsync(context.Document, diagnostic, + Patterns.MockFileSystemAddFilesFromEmbeddedNamespace, ct), equivalenceKey: Patterns.MockFileSystemAddFilesFromEmbeddedNamespace), diagnostic); } - private static async Task ApplyAddFilesFromEmbeddedNamespaceRewriteAsync( - Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + private static CompilationUnitSyntax? ApplyAddFilesFromEmbeddedNamespacePure( + CompilationUnitSyntax cu, SyntaxNode target, SemanticModel semanticModel, CancellationToken cancellationToken) { - SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - if (root is not CompilationUnitSyntax compilationUnit) - { - return document; - } - - SemanticModel? semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); - if (semanticModel is null) - { - return document; - } - - SyntaxNode? node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); - InvocationExpressionSyntax? invocation = node?.FirstAncestorOrSelf(); + InvocationExpressionSyntax? invocation = target.FirstAncestorOrSelf(); if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess || invocation.ArgumentList.Arguments.Count != 3) { - return document; + return null; } ArgumentSyntax pathArg = invocation.ArgumentList.Arguments[0]; @@ -1662,7 +1580,7 @@ private static async Task ApplyAddFilesFromEmbeddedNamespaceRewriteAsy cancellationToken, out string? relativePath)) { - return document; + return null; } MemberAccessExpressionSyntax newAccess = SyntaxFactory.MemberAccessExpression( @@ -1691,9 +1609,7 @@ private static async Task ApplyAddFilesFromEmbeddedNamespaceRewriteAsy InvocationExpressionSyntax replacement = SyntaxFactory.InvocationExpression( newAccess, SyntaxFactory.ArgumentList(args)); - compilationUnit = compilationUnit.ReplaceNode(invocation, replacement.WithTriviaFrom(invocation)); - compilationUnit = EnsureTestablyUsing(compilationUnit); - return document.WithSyntaxRoot(compilationUnit); + return cu.ReplaceNode(invocation, replacement.WithTriviaFrom(invocation)); } private static bool TryComputeRelativePathFromAssemblyAndLiteral( @@ -1790,6 +1706,190 @@ is IMethodSymbol return null; } + // ── Shared: pattern dispatch & using-directive policy ─────────────────── + + /// + /// What change a pattern's rewrite needs to apply to the file's using directives + /// once the syntax rewrite is in place. Ordered by strength: + /// subsumes , which subsumes . + /// + internal enum UsingChange + { + None = 0, + Ensure = 1, + Swap = 2, + } + + /// + /// Maps a diagnostic id to the using-directive change its + /// rewrite needs. Patterns whose rewrite would leave the receiver typed as the + /// concrete System.IO.Abstractions.TestingHelpers.MockFileSystem require a + /// (replace the using); patterns whose rewrite is an + /// extension method that needs the Testably namespace visible but does not retarget + /// the receiver require an (add the using). + /// + internal static UsingChange GetUsingChange(string pattern) => pattern switch + { + Patterns.MockFileSystemDefaultConstructor => UsingChange.Swap, + Patterns.MockFileSystemOptionsConstructor => UsingChange.Swap, + Patterns.MockFileSystemFilesConstructor => UsingChange.Swap, + Patterns.MockFileSystemFilesOptionsConstructor => UsingChange.Swap, + Patterns.MockFileSystemAddDrive => UsingChange.Swap, + Patterns.MockFileSystemAddFilesFromEmbeddedNamespace => UsingChange.Ensure, + _ => UsingChange.None, + }; + + /// + /// Applies a using-directive change to the compilation unit. Called once at the end + /// of a fix pipeline so that overlapping using edits from multiple rewrites collapse + /// into a single deterministic change. + /// + internal static CompilationUnitSyntax ApplyUsingChange(CompilationUnitSyntax cu, UsingChange change) + => change switch + { + UsingChange.Swap => SwapToTestablyUsing(cu), + UsingChange.Ensure => EnsureTestablyUsing(cu), + _ => cu, + }; + + /// + /// Single-diagnostic entry point used by every per-pattern CodeAction. Re-runs the + /// analyzer to discover all sibling diagnostics in the same document, then routes + /// through + /// so one click in the IDE migrates the whole file at once. This mirrors + /// Mockolate's per-diagnostic Fix behavior and is the only way to keep the file + /// compilable after the fix — a per-diagnostic constructor fix that only swaps + /// the using leaves dependent AddFile / accessor calls bound to a Testably + /// type that has no matching surface, producing non-compiling code that the + /// analyzer can no longer flag. + /// + internal static async Task ApplySinglePatternAsync( + Document document, + Diagnostic diagnostic, + string pattern, + CancellationToken cancellationToken) + { + ImmutableArray siblings = await GetSiblingDiagnosticsAsync(document, cancellationToken) + .ConfigureAwait(false); + if (siblings.IsEmpty) + { + // Analyzer-run failed (no Compilation available, etc.). Fall back to a + // single-diagnostic apply so the user still gets some migration progress. + return await ApplyOnePatternAsync(document, diagnostic, pattern, cancellationToken) + .ConfigureAwait(false); + } + + Document? result = await SystemIOAbstractionsFixAllProvider + .MigrateDocumentAsync(document, siblings, cancellationToken).ConfigureAwait(false); + return result ?? document; + } + + /// + /// Re-runs against the document's + /// compilation and returns every TestablyM001 diagnostic whose source tree matches + /// . Returns an empty array if the analyzer cannot be + /// run (no compilation, cancelled, etc.) — callers fall back to single-diagnostic + /// application in that case. + /// + private static async Task> GetSiblingDiagnosticsAsync( + Document document, CancellationToken cancellationToken) + { + Compilation? compilation = await document.Project + .GetCompilationAsync(cancellationToken).ConfigureAwait(false); + SyntaxTree? tree = await document.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false); + if (compilation is null || tree is null) + { + return ImmutableArray.Empty; + } + + CompilationWithAnalyzers compilationWithAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(new SystemIOAbstractionsAnalyzer()), + document.Project.AnalyzerOptions); + ImmutableArray all = await compilationWithAnalyzers + .GetAnalyzerDiagnosticsAsync(cancellationToken).ConfigureAwait(false); + + ImmutableArray.Builder result = ImmutableArray.CreateBuilder(); + foreach (Diagnostic d in all) + { + if (d.Id == Rules.SystemIOAbstractionsRule.Id && d.Location.SourceTree == tree) + { + result.Add(d); + } + } + + return result.ToImmutable(); + } + + /// + /// Fallback single-diagnostic apply used when the analyzer can't be re-run to find + /// siblings. Preserves the original per-diagnostic semantics (load document state, + /// dispatch one pattern, apply using-directive change). + /// + private static async Task ApplyOnePatternAsync( + Document document, + Diagnostic diagnostic, + string pattern, + CancellationToken cancellationToken) + { + SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is not CompilationUnitSyntax cu) + { + return document; + } + + SyntaxNode? target = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + if (target is null) + { + return document; + } + + SemanticModel? semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + + CompilationUnitSyntax? rewritten = DispatchPure(pattern, cu, target, semanticModel, cancellationToken); + if (rewritten is null) + { + return document; + } + + rewritten = ApplyUsingChange(rewritten, GetUsingChange(pattern)); + return document.WithSyntaxRoot(rewritten); + } + + /// + /// Pure pattern dispatch. Each case forwards to a pure rewriter that takes a + /// plus the diagnostic's target node and + /// returns a new compilation unit (or if the rewrite is not + /// applicable). No I/O, no using-swap — those belong to the caller. + /// + internal static CompilationUnitSyntax? DispatchPure( + string pattern, + CompilationUnitSyntax cu, + SyntaxNode target, + SemanticModel? semanticModel, + CancellationToken cancellationToken) + => pattern switch + { + Patterns.MockFileSystemDefaultConstructor => ApplyDefaultCtorPure(cu, target), + Patterns.MockFileSystemOptionsConstructor => ApplyOptionsCtorPure(cu, target), + Patterns.AccessorAddDirectory + or Patterns.AccessorRemoveFile + or Patterns.AccessorMoveDirectory + or Patterns.AccessorFileExists + or Patterns.AccessorAddEmptyFile + => ApplyAccessorMethodPure(cu, target, pattern), + Patterns.AccessorAddFile when semanticModel is not null + => ApplyAddFilePure(cu, target, semanticModel, cancellationToken), + Patterns.MockFileSystemFilesConstructor or Patterns.MockFileSystemFilesOptionsConstructor + when semanticModel is not null + => ApplyFilesCtorPure(cu, target, semanticModel, pattern, cancellationToken), + Patterns.MockFileDataPropertyRead => ApplyPropertyReadPure(cu, target), + Patterns.MockFileDataPropertyWrite => ApplyPropertyWritePure(cu, target), + Patterns.MockFileSystemAddDrive => ApplyAddDrivePure(cu, target), + Patterns.MockFileSystemAddFilesFromEmbeddedNamespace when semanticModel is not null + => ApplyAddFilesFromEmbeddedNamespacePure(cu, target, semanticModel, cancellationToken), + _ => null, + }; + // ── Shared: using-directive swap ───────────────────────────────────────── private static CompilationUnitSyntax EnsureTestablyUsing(CompilationUnitSyntax compilationUnit) diff --git a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsFixAllProvider.cs b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsFixAllProvider.cs new file mode 100644 index 0000000..98998d3 --- /dev/null +++ b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsFixAllProvider.cs @@ -0,0 +1,176 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Testably.Abstractions.Migration.Analyzers; + +/// +/// Fix-all provider for . Processes +/// every diagnostic in a document in a single pass, so that the using-directive swap +/// happens exactly once at the end instead of once per rewrite. The default +/// drops fixes whose text changes +/// overlap (every per-diagnostic rewrite that touches the using line collides), and +/// once the using is swapped the analyzer no longer fires on the remaining +/// constructors — leaving the file partially migrated and non-compiling. +/// +internal sealed class SystemIOAbstractionsFixAllProvider : DocumentBasedFixAllProvider +{ + public static readonly SystemIOAbstractionsFixAllProvider Instance = new(); + + private SystemIOAbstractionsFixAllProvider() + { + } + + protected override Task FixAllAsync( + FixAllContext fixAllContext, + Document document, + ImmutableArray diagnostics) + => MigrateDocumentAsync(document, diagnostics, fixAllContext.CancellationToken); + + /// + /// Migrates every diagnostic in within + /// in a single pass: annotate target nodes, dispatch + /// each pattern's pure rewriter sequentially with re-acquired semantic models, + /// then apply the using-directive change exactly once. Used both by the fix-all + /// scopes (Document/Project/Solution) and by the single-diagnostic CodeAction — + /// the per-diagnostic Fix re-discovers all sibling diagnostics in the document + /// and routes through here so one click migrates the whole file (Mockolate-style). + /// + internal static async Task MigrateDocumentAsync( + Document document, + ImmutableArray diagnostics, + CancellationToken cancellationToken) + { + if (diagnostics.IsEmpty) + { + return document; + } + + SyntaxNode? root = await document + .GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is not CompilationUnitSyntax originalCu) + { + return document; + } + + // Source-order pass: annotate each diagnostic's target node so we can locate it + // after prior rewrites have transformed its surroundings. The same diagnostic id + // may legitimately appear on overlapping spans (e.g. nested initializers); a + // fresh annotation per work item keeps them addressable independently. + List work = []; + Dictionary> nodeToAnnotations = new(); + foreach (Diagnostic diagnostic in diagnostics.OrderBy(d => d.Location.SourceSpan.Start)) + { + if (!diagnostic.Properties.TryGetValue(Patterns.Key, out string? pattern) || pattern is null) + { + continue; + } + + SyntaxNode? target = originalCu.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + if (target is null) + { + continue; + } + + SyntaxAnnotation annotation = new(); + work.Add(new WorkItem(pattern, annotation)); + + if (!nodeToAnnotations.TryGetValue(target, out List? list)) + { + list = []; + nodeToAnnotations[target] = list; + } + + list.Add(annotation); + } + + if (work.Count == 0) + { + return document; + } + + CompilationUnitSyntax annotatedCu = originalCu.ReplaceNodes( + nodeToAnnotations.Keys, + (original, _) => original.WithAdditionalAnnotations([..nodeToAnnotations[original]])); + + Document currentDoc = document.WithSyntaxRoot(annotatedCu); + SystemIOAbstractionsCodeFixProvider.UsingChange maxUsingChange = + SystemIOAbstractionsCodeFixProvider.UsingChange.None; + + foreach (WorkItem item in work) + { + SyntaxNode? curRoot = await currentDoc + .GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (curRoot is not CompilationUnitSyntax curCu) + { + continue; + } + + // Annotations propagate through ReplaceNode/ReplaceNodes automatically. A null + // here means a prior block-level rewrite (AddFile/FilesCtor initializer + // expansion) replaced an enclosing statement and the annotated node was + // dropped — skip it; the surrounding rewrite already covered it. + SyntaxNode? currentTarget = curCu.GetAnnotatedNodes(item.Annotation).FirstOrDefault(); + if (currentTarget is null) + { + continue; + } + + // Re-acquire the semantic model from the live document. Earlier rewrites + // change the syntax tree; the original-document semantic model would return + // stale or null symbol info for nodes in the new tree. + SemanticModel? semanticModel = await currentDoc + .GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + + CompilationUnitSyntax? rewritten = SystemIOAbstractionsCodeFixProvider.DispatchPure( + item.Pattern, curCu, currentTarget, semanticModel, cancellationToken); + if (rewritten is null) + { + continue; + } + + currentDoc = currentDoc.WithSyntaxRoot(rewritten); + + SystemIOAbstractionsCodeFixProvider.UsingChange change = + SystemIOAbstractionsCodeFixProvider.GetUsingChange(item.Pattern); + if (change > maxUsingChange) + { + maxUsingChange = change; + } + } + + // Apply the using-directive change once, after every syntax rewrite. Doing it + // here (instead of inside each pattern's rewrite) is the entire point of this + // provider: it keeps the analyzer firing on remaining diagnostics during the + // pass, and produces a single deterministic text change at the using line. + if (maxUsingChange != SystemIOAbstractionsCodeFixProvider.UsingChange.None) + { + SyntaxNode? finalRoot = await currentDoc + .GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (finalRoot is CompilationUnitSyntax finalCu) + { + finalCu = SystemIOAbstractionsCodeFixProvider.ApplyUsingChange(finalCu, maxUsingChange); + currentDoc = currentDoc.WithSyntaxRoot(finalCu); + } + } + + return currentDoc; + } + + private readonly struct WorkItem + { + public WorkItem(string pattern, SyntaxAnnotation annotation) + { + Pattern = pattern; + Annotation = annotation; + } + + public string Pattern { get; } + public SyntaxAnnotation Annotation { get; } + } +} diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.FixAllTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.FixAllTests.cs new file mode 100644 index 0000000..713c083 --- /dev/null +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.FixAllTests.cs @@ -0,0 +1,155 @@ +using Testably.Abstractions.Migration.Analyzers; +using Verifier = + Testably.Abstractions.Migration.Tests.Verifiers.CSharpCodeFixVerifier< + Testably.Abstractions.Migration.Analyzers.SystemIOAbstractionsAnalyzer, + Testably.Abstractions.Migration.Analyzers.SystemIOAbstractionsCodeFixProvider>; + +namespace Testably.Abstractions.Migration.Tests; + +public partial class SystemIOAbstractionsCodeFixProviderTests +{ + /// + /// End-to-end tests that exercise the custom SystemIOAbstractionsFixAllProvider. + /// Each test puts multiple diagnostics in one source file so the test framework's + /// fix-all verification (Document / Project / Solution scope) runs the provider. + /// The default WellKnownFixAllProviders.BatchFixer drops fixes whose text + /// changes overlap on the using line, and once the first using swap lands the + /// analyzer no longer fires on remaining constructors — these cases would fail + /// to migrate fully under BatchFixer. + /// + public sealed class FixAllTests + { + [Fact] + public async Task MultipleDefaultConstructors_InSameDocument_ShouldAllMigrate() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public MockFileSystem Build1() => {|#0:new MockFileSystem()|}; + public MockFileSystem Build2() => {|#1:new MockFileSystem()|}; + public MockFileSystem Build3() => {|#2:new MockFileSystem()|}; + } + """; + + const string fixedSource = """ + using Testably.Abstractions.Testing; + + public class C + { + public MockFileSystem Build1() => new MockFileSystem(); + public MockFileSystem Build2() => new MockFileSystem(); + public MockFileSystem Build3() => new MockFileSystem(); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + [ + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(1), + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(2), + ], + fixedSource); + } + + [Fact] + public async Task ConstructorPlusAccessorCalls_OnSameMockFileSystem_ShouldAllMigrate() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Seed() + { + MockFileSystem fs = {|#0:new MockFileSystem()|}; + {|#1:fs.AddDirectory("/b")|}; + {|#2:fs.AddFile("/a", new MockFileData("x"))|}; + } + } + """; + + const string fixedSource = """ + using Testably.Abstractions.Testing; + + public class C + { + public void Seed() + { + MockFileSystem fs = new MockFileSystem(); + fs.Directory.CreateDirectory("/b"); + fs.File.WriteAllText("/a", "x"); + } + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + [ + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(1), + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(2), + ], + fixedSource); + } + + [Fact] + public async Task MixedPatterns_AcrossMultipleMethods_ShouldAllMigrateInOnePass() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void First() + { + MockFileSystem fs = {|#0:new MockFileSystem()|}; + {|#1:fs.AddFile("/a", new MockFileData("hello"))|}; + } + + public void Second() + { + MockFileSystem fs = {|#2:new MockFileSystem()|}; + {|#3:fs.AddDirectory("/dir")|}; + } + + public string Read(MockFileSystem fs) => {|#4:fs.GetFile("/a").TextContents|}; + } + """; + + const string fixedSource = """ + using Testably.Abstractions.Testing; + + public class C + { + public void First() + { + MockFileSystem fs = new MockFileSystem(); + fs.File.WriteAllText("/a", "hello"); + } + + public void Second() + { + MockFileSystem fs = new MockFileSystem(); + fs.Directory.CreateDirectory("/dir"); + } + + public string Read(MockFileSystem fs) => fs.File.ReadAllText("/a"); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + [ + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(1), + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(2), + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(3), + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(4), + ], + fixedSource); + } + } +} From ecd68ce848db83a4cbff797e99e45681efcaa9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 16 May 2026 10:47:07 +0200 Subject: [PATCH 2/2] refactor: split MigrateDocumentAsync and skip per-iteration recompile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract CollectAndAnnotate + ApplyRewritesAsync helpers so each method stays under Sonar's cognitive-complexity ceiling of 15 (the original MigrateDocumentAsync hit 23). Hoist maxUsingChange computation from the work-item intent before the rewrite loop, so a block-level rewrite that absorbs a sibling annotated node still applies the absorbed item's using-directive change. Guard the final swap with anyRewriteSucceeded so a no-op pass doesn't add a Testably using to a still-TestingHelpers-bound file. Skip the Document round-trip for purely syntactic patterns (most of them) and only sync through a fresh SemanticModel for AccessorAddFile, FilesCtor*, and AddFilesFromEmbeddedNamespace. When a sync is necessary, re-locate the annotated target in the round-tripped tree — Roslyn's WithSyntaxRoot may produce a tree whose nodes are not reference-equal to the input, which would make SemanticModel.GetSymbolInfo reject them. --- .../SystemIOAbstractionsCodeFixProvider.cs | 14 +- .../SystemIOAbstractionsFixAllProvider.cs | 168 ++++++++++++------ 2 files changed, 129 insertions(+), 53 deletions(-) diff --git a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs index 5a07c1d..ae3b9bd 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs @@ -1739,6 +1739,17 @@ internal enum UsingChange _ => UsingChange.None, }; + /// + /// Whether a pattern's pure rewriter requires a . + /// Used by the fix-all loop to avoid an O(N) per-iteration document round-trip + /// when the rewrite is purely syntactic — most patterns fall in that bucket. + /// + internal static bool PatternNeedsSemanticModel(string pattern) => pattern + is Patterns.AccessorAddFile + or Patterns.MockFileSystemFilesConstructor + or Patterns.MockFileSystemFilesOptionsConstructor + or Patterns.MockFileSystemAddFilesFromEmbeddedNamespace; + /// /// Applies a using-directive change to the compilation unit. Called once at the end /// of a fix pipeline so that overlapping using edits from multiple rewrites collapse @@ -1779,9 +1790,8 @@ internal static async Task ApplySinglePatternAsync( .ConfigureAwait(false); } - Document? result = await SystemIOAbstractionsFixAllProvider + return await SystemIOAbstractionsFixAllProvider .MigrateDocumentAsync(document, siblings, cancellationToken).ConfigureAwait(false); - return result ?? document; } /// diff --git a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsFixAllProvider.cs b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsFixAllProvider.cs index 98998d3..47326c9 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsFixAllProvider.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsFixAllProvider.cs @@ -26,11 +26,12 @@ private SystemIOAbstractionsFixAllProvider() { } - protected override Task FixAllAsync( + protected override async Task FixAllAsync( FixAllContext fixAllContext, Document document, ImmutableArray diagnostics) - => MigrateDocumentAsync(document, diagnostics, fixAllContext.CancellationToken); + => await MigrateDocumentAsync(document, diagnostics, fixAllContext.CancellationToken) + .ConfigureAwait(false); /// /// Migrates every diagnostic in within @@ -41,7 +42,7 @@ private SystemIOAbstractionsFixAllProvider() /// the per-diagnostic Fix re-discovers all sibling diagnostics in the document /// and routes through here so one click migrates the whole file (Mockolate-style). /// - internal static async Task MigrateDocumentAsync( + internal static async Task MigrateDocumentAsync( Document document, ImmutableArray diagnostics, CancellationToken cancellationToken) @@ -58,10 +59,51 @@ private SystemIOAbstractionsFixAllProvider() return document; } - // Source-order pass: annotate each diagnostic's target node so we can locate it - // after prior rewrites have transformed its surroundings. The same diagnostic id - // may legitimately appear on overlapping spans (e.g. nested initializers); a - // fresh annotation per work item keeps them addressable independently. + (CompilationUnitSyntax annotatedCu, List work) = CollectAndAnnotate(originalCu, diagnostics); + if (work.Count == 0) + { + return document; + } + + // Compute the using-directive change up front from work-item intent rather than + // from rewrite success. A block-level rewrite (e.g. FilesCtor initializer + // expansion) can absorb a sibling annotated node so that its later dispatch + // returns null — but the absorbed item's using-change contribution must still + // land, otherwise the file would be left half-migrated. + SystemIOAbstractionsCodeFixProvider.UsingChange maxUsingChange = + SystemIOAbstractionsCodeFixProvider.UsingChange.None; + foreach (WorkItem item in work) + { + SystemIOAbstractionsCodeFixProvider.UsingChange change = + SystemIOAbstractionsCodeFixProvider.GetUsingChange(item.Pattern); + if (change > maxUsingChange) + { + maxUsingChange = change; + } + } + + (CompilationUnitSyntax finalCu, bool anyRewriteSucceeded) = await ApplyRewritesAsync( + document, annotatedCu, work, cancellationToken).ConfigureAwait(false); + + // Suppress the using swap when no rewrite landed. Otherwise we'd add the Testably + // using to a file whose code still references TestingHelpers — non-compiling. + if (anyRewriteSucceeded + && maxUsingChange != SystemIOAbstractionsCodeFixProvider.UsingChange.None) + { + finalCu = SystemIOAbstractionsCodeFixProvider.ApplyUsingChange(finalCu, maxUsingChange); + } + + return document.WithSyntaxRoot(finalCu); + } + + /// + /// Walks the diagnostics in source order, finds each one's target node in the + /// original compilation unit, and annotates every target so subsequent rewrites + /// can locate it via . + /// + private static (CompilationUnitSyntax AnnotatedCu, List Work) CollectAndAnnotate( + CompilationUnitSyntax originalCu, ImmutableArray diagnostics) + { List work = []; Dictionary> nodeToAnnotations = new(); foreach (Diagnostic diagnostic in diagnostics.OrderBy(d => d.Location.SourceSpan.Start)) @@ -91,75 +133,99 @@ private SystemIOAbstractionsFixAllProvider() if (work.Count == 0) { - return document; + return (originalCu, work); } CompilationUnitSyntax annotatedCu = originalCu.ReplaceNodes( nodeToAnnotations.Keys, (original, _) => original.WithAdditionalAnnotations([..nodeToAnnotations[original]])); + return (annotatedCu, work); + } - Document currentDoc = document.WithSyntaxRoot(annotatedCu); - SystemIOAbstractionsCodeFixProvider.UsingChange maxUsingChange = - SystemIOAbstractionsCodeFixProvider.UsingChange.None; + /// + /// Applies each pattern's pure rewriter to in + /// sequence. Stays in-memory between iterations for purely-syntactic patterns and + /// only round-trips through a fresh / + /// when the pattern actually needs semantic info — + /// that avoids an O(N) re-compile per rewrite for the (common) syntactic patterns. + /// When a round-trip is necessary, both the rewrite target and the semantic model + /// are taken from the round-tripped tree, since + /// may produce a tree whose nodes are not reference-equal to the input. + /// + private static async Task<(CompilationUnitSyntax FinalCu, bool AnyRewriteSucceeded)> ApplyRewritesAsync( + Document originalDocument, + CompilationUnitSyntax annotatedCu, + List work, + CancellationToken cancellationToken) + { + CompilationUnitSyntax currentCu = annotatedCu; + bool anyRewriteSucceeded = false; foreach (WorkItem item in work) { - SyntaxNode? curRoot = await currentDoc - .GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - if (curRoot is not CompilationUnitSyntax curCu) + CompilationUnitSyntax? rewritten; + if (SystemIOAbstractionsCodeFixProvider.PatternNeedsSemanticModel(item.Pattern)) { - continue; + rewritten = await DispatchWithSemanticModelAsync( + originalDocument, currentCu, item, cancellationToken).ConfigureAwait(false); } - - // Annotations propagate through ReplaceNode/ReplaceNodes automatically. A null - // here means a prior block-level rewrite (AddFile/FilesCtor initializer - // expansion) replaced an enclosing statement and the annotated node was - // dropped — skip it; the surrounding rewrite already covered it. - SyntaxNode? currentTarget = curCu.GetAnnotatedNodes(item.Annotation).FirstOrDefault(); - if (currentTarget is null) + else { - continue; + SyntaxNode? currentTarget = currentCu.GetAnnotatedNodes(item.Annotation).FirstOrDefault(); + if (currentTarget is null) + { + continue; + } + + rewritten = SystemIOAbstractionsCodeFixProvider.DispatchPure( + item.Pattern, currentCu, currentTarget, semanticModel: null, cancellationToken); } - // Re-acquire the semantic model from the live document. Earlier rewrites - // change the syntax tree; the original-document semantic model would return - // stale or null symbol info for nodes in the new tree. - SemanticModel? semanticModel = await currentDoc - .GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); - - CompilationUnitSyntax? rewritten = SystemIOAbstractionsCodeFixProvider.DispatchPure( - item.Pattern, curCu, currentTarget, semanticModel, cancellationToken); if (rewritten is null) { continue; } - currentDoc = currentDoc.WithSyntaxRoot(rewritten); + currentCu = rewritten; + anyRewriteSucceeded = true; + } - SystemIOAbstractionsCodeFixProvider.UsingChange change = - SystemIOAbstractionsCodeFixProvider.GetUsingChange(item.Pattern); - if (change > maxUsingChange) - { - maxUsingChange = change; - } + return (currentCu, anyRewriteSucceeded); + } + + /// + /// Round-trips through a document so the semantic + /// model binds to the live tree, then re-locates the work item's annotated target + /// in that round-tripped tree and dispatches the pattern's pure rewriter against + /// it. Returns the new compilation unit (still annotation-bearing) or + /// if the target was absorbed or the rewrite is not + /// applicable. + /// + private static async Task DispatchWithSemanticModelAsync( + Document originalDocument, + CompilationUnitSyntax currentCu, + WorkItem item, + CancellationToken cancellationToken) + { + Document syncedDoc = originalDocument.WithSyntaxRoot(currentCu); + SyntaxNode? syncedRoot = await syncedDoc + .GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (syncedRoot is not CompilationUnitSyntax syncedCu) + { + return null; } - // Apply the using-directive change once, after every syntax rewrite. Doing it - // here (instead of inside each pattern's rewrite) is the entire point of this - // provider: it keeps the analyzer firing on remaining diagnostics during the - // pass, and produces a single deterministic text change at the using line. - if (maxUsingChange != SystemIOAbstractionsCodeFixProvider.UsingChange.None) + SyntaxNode? syncedTarget = syncedCu.GetAnnotatedNodes(item.Annotation).FirstOrDefault(); + if (syncedTarget is null) { - SyntaxNode? finalRoot = await currentDoc - .GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - if (finalRoot is CompilationUnitSyntax finalCu) - { - finalCu = SystemIOAbstractionsCodeFixProvider.ApplyUsingChange(finalCu, maxUsingChange); - currentDoc = currentDoc.WithSyntaxRoot(finalCu); - } + return null; } - return currentDoc; + SemanticModel? semanticModel = await syncedDoc + .GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + + return SystemIOAbstractionsCodeFixProvider.DispatchPure( + item.Pattern, syncedCu, syncedTarget, semanticModel, cancellationToken); } private readonly struct WorkItem