From 9ee4b7d7304bf45c11b74314afae740f5d069c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 14 May 2026 18:57:08 +0200 Subject: [PATCH 1/3] feat: Phase 2 constructor + accessor-method migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the analyzer with one discriminator per supported pattern and dispatch each to a dedicated code-fix rewrite. All patterns share `TestablyAbstractionsMigration001`. Constructors - `new MockFileSystem(MockFileSystemOptions { CurrentDirectory = "x" })` folds into `new MockFileSystem(o => o.UseCurrentDirectory("x"))`. Empty initializer collapses to the parameterless form. Properties without a Testably equivalent (`CreateDefaultTempDir`, etc.) emit no fix. - `new MockFileSystem(dictLiteral[, "currentDir"])` and `new MockFileSystem( dictLiteral, optionsLiteral)` expand at the call site when assigned to a single local in a block: the constructor becomes parameterless / options-only, and one `fs.File.WriteAllText(...)` / `WriteAllBytes(...)` statement is appended per dictionary entry. Each entry is classified semantically (string vs encoded vs bytes); any unsupported `MockFileData` shape disables the whole expansion. 1:1 accessor methods (IMockFileDataAccessor surface) - `AddDirectory` → `Directory.CreateDirectory` - `RemoveFile` → `File.Delete` (drops `verifyAccess`) - `MoveDirectory`→ `Directory.Move` - `FileExists` → `File.Exists` - `AddEmptyFile` → `File.Create(p).Dispose()` - `AddFile` → `File.WriteAllText[, encoding]` or `WriteAllBytes`, dispatched on the `MockFileData` constructor overload via the semantic model. Non-literal data or initializer-with-properties cases register no fix. Implementation notes - Follow-up statements for the dictionary expansion are produced via `ParseStatement(text)` rather than `SyntaxFactory.ExpressionStatement`. The latter emits elastic trivia that the Formatter normalizes alongside the enclosing block's closing brace, breaking the source's indentation style. - The playground accumulates one sample per Phase 2 pattern so the parity check exercises the full fixer dispatch table. --- .../SystemIOAbstractionsCodeFixProvider.cs | 728 +++++++++++++++++- .../Patterns.cs | 35 + .../SystemIOAbstractionsAnalyzer.cs | 88 ++- .../MockFileSystemSamples.cs | 58 +- .../SystemIOAbstractionsAnalyzerTests.cs | 87 ++- ...ystemIOAbstractionsCodeFixProviderTests.cs | 494 ++++++++++++ 6 files changed, 1456 insertions(+), 34 deletions(-) diff --git a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs index 48369c4..bfb74d2 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Collections.Immutable; using System.Composition; using System.Linq; @@ -31,8 +32,15 @@ public override ImmutableArray FixableDiagnosticIds => WellKnownFixAllProviders.BatchFixer; /// - public override Task RegisterCodeFixesAsync(CodeFixContext context) + public override async Task RegisterCodeFixesAsync(CodeFixContext context) { + SyntaxNode? root = await context.Document + .GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is not CompilationUnitSyntax) + { + return; + } + foreach (Diagnostic diagnostic in context.Diagnostics) { if (!diagnostic.Properties.TryGetValue(Patterns.Key, out string? pattern) || pattern is null) @@ -40,20 +48,49 @@ public override Task RegisterCodeFixesAsync(CodeFixContext context) continue; } + SyntaxNode? node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + if (node is null) + { + continue; + } + switch (pattern) { case Patterns.MockFileSystemDefaultConstructor: - context.RegisterCodeFix( - CodeAction.Create( - Resources.TestablyAbstractionsMigration001CodeFixTitle, - ct => RewriteUsingsAsync(context.Document, ct), - equivalenceKey: Patterns.MockFileSystemDefaultConstructor), - diagnostic); + RegisterDefaultCtorFix(context, diagnostic); + break; + case Patterns.MockFileSystemOptionsConstructor: + TryRegisterOptionsCtorFix(context, diagnostic, node); + break; + case Patterns.AccessorAddDirectory: + case Patterns.AccessorRemoveFile: + case Patterns.AccessorMoveDirectory: + case Patterns.AccessorFileExists: + case Patterns.AccessorAddEmptyFile: + TryRegisterAccessorMethodFix(context, diagnostic, node, pattern); + break; + case Patterns.AccessorAddFile: + await TryRegisterAddFileFixAsync(context, diagnostic, node).ConfigureAwait(false); + break; + case Patterns.MockFileSystemFilesConstructor: + case Patterns.MockFileSystemFilesOptionsConstructor: + await TryRegisterFilesCtorFixAsync(context, diagnostic, node, pattern) + .ConfigureAwait(false); break; } } + } + + // ── Pattern: MockFileSystem.ctor() ─────────────────────────────────────── - return Task.CompletedTask; + private static void RegisterDefaultCtorFix(CodeFixContext context, Diagnostic diagnostic) + { + context.RegisterCodeFix( + CodeAction.Create( + Resources.TestablyAbstractionsMigration001CodeFixTitle, + ct => RewriteUsingsAsync(context.Document, ct), + equivalenceKey: Patterns.MockFileSystemDefaultConstructor), + diagnostic); } private static async Task RewriteUsingsAsync(Document document, CancellationToken cancellationToken) @@ -64,6 +101,668 @@ private static async Task RewriteUsingsAsync(Document document, Cancel return document; } + compilationUnit = SwapToTestablyUsing(compilationUnit); + return document.WithSyntaxRoot(compilationUnit); + } + + // ── Pattern: MockFileSystem.ctor(options) ──────────────────────────────── + + private static void TryRegisterOptionsCtorFix(CodeFixContext context, Diagnostic diagnostic, SyntaxNode node) + { + ObjectCreationExpressionSyntax? creation = node.FirstAncestorOrSelf(); + if (creation?.ArgumentList is not { Arguments.Count: 1, } argList) + { + return; + } + + if (TryBuildTestablyOptionsArgList(argList.Arguments[0].Expression) is null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + Resources.TestablyAbstractionsMigration001CodeFixTitle, + ct => ApplyOptionsCtorRewriteAsync(context.Document, diagnostic, ct), + equivalenceKey: Patterns.MockFileSystemOptionsConstructor), + diagnostic); + } + + private static async Task ApplyOptionsCtorRewriteAsync( + Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + 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(); + if (creation?.ArgumentList is not { Arguments.Count: 1, } argList) + { + return document; + } + + ArgumentListSyntax? newArgList = TryBuildTestablyOptionsArgList(argList.Arguments[0].Expression); + if (newArgList is null) + { + return document; + } + + ObjectCreationExpressionSyntax newCreation = + creation.WithArgumentList(newArgList.WithTriviaFrom(argList)); + compilationUnit = compilationUnit.ReplaceNode(creation, newCreation); + compilationUnit = SwapToTestablyUsing(compilationUnit); + + return document.WithSyntaxRoot(compilationUnit); + } + + private static ArgumentListSyntax? TryBuildTestablyOptionsArgList(ExpressionSyntax optionsExpression) + { + InitializerExpressionSyntax? initializer = optionsExpression switch + { + ObjectCreationExpressionSyntax oc => oc.Initializer, + ImplicitObjectCreationExpressionSyntax ic => ic.Initializer, + _ => null, + }; + + bool isCreation = + optionsExpression is ObjectCreationExpressionSyntax + or ImplicitObjectCreationExpressionSyntax; + if (!isCreation) + { + return null; + } + + if (initializer is null || initializer.Expressions.Count == 0) + { + return SyntaxFactory.ArgumentList(); + } + + ExpressionSyntax? currentDirectoryRhs = null; + foreach (ExpressionSyntax expression in initializer.Expressions) + { + if (expression is not AssignmentExpressionSyntax assignment + || assignment.Left is not IdentifierNameSyntax property) + { + return null; + } + + switch (property.Identifier.Text) + { + case "CurrentDirectory": + currentDirectoryRhs = assignment.Right; + break; + default: + return null; + } + } + + if (currentDirectoryRhs is null) + { + return SyntaxFactory.ArgumentList(); + } + + SimpleLambdaExpressionSyntax lambda = SyntaxFactory.SimpleLambdaExpression( + SyntaxFactory.Parameter(SyntaxFactory.Identifier("o")), + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("o"), + SyntaxFactory.IdentifierName("UseCurrentDirectory")), + SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(currentDirectoryRhs.WithoutTrivia()))))); + + return SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(lambda))); + } + + // ── Pattern: 1:1 IMockFileDataAccessor method rewrites ─────────────────── + + private static void TryRegisterAccessorMethodFix( + CodeFixContext context, Diagnostic diagnostic, SyntaxNode node, string pattern) + { + InvocationExpressionSyntax? invocation = node.FirstAncestorOrSelf(); + if (invocation?.Expression is not MemberAccessExpressionSyntax) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + Resources.TestablyAbstractionsMigration001CodeFixTitle, + ct => ApplyAccessorMethodRewriteAsync(context.Document, diagnostic, pattern, ct), + equivalenceKey: pattern), + diagnostic); + } + + private static async Task ApplyAccessorMethodRewriteAsync( + Document document, Diagnostic diagnostic, string pattern, CancellationToken cancellationToken) + { + 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(); + if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess) + { + return document; + } + + ExpressionSyntax newExpression = pattern switch + { + Patterns.AccessorAddDirectory => BuildSubReceiverInvocation( + invocation, memberAccess, "Directory", "CreateDirectory", argCountToKeep: int.MaxValue), + Patterns.AccessorRemoveFile => BuildSubReceiverInvocation( + invocation, memberAccess, "File", "Delete", argCountToKeep: 1), + Patterns.AccessorMoveDirectory => BuildSubReceiverInvocation( + invocation, memberAccess, "Directory", "Move", argCountToKeep: int.MaxValue), + Patterns.AccessorFileExists => BuildSubReceiverInvocation( + invocation, memberAccess, "File", "Exists", argCountToKeep: int.MaxValue), + Patterns.AccessorAddEmptyFile => BuildAddEmptyFileInvocation(invocation, memberAccess), + _ => invocation, + }; + + if (ReferenceEquals(newExpression, invocation)) + { + return document; + } + + compilationUnit = compilationUnit.ReplaceNode(invocation, newExpression.WithTriviaFrom(invocation)); + return document.WithSyntaxRoot(compilationUnit); + } + + private static InvocationExpressionSyntax BuildSubReceiverInvocation( + InvocationExpressionSyntax original, + MemberAccessExpressionSyntax memberAccess, + string subReceiver, + string newMethod, + int argCountToKeep) + { + MemberAccessExpressionSyntax newAccess = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + memberAccess.Expression, + SyntaxFactory.IdentifierName(subReceiver)), + SyntaxFactory.IdentifierName(newMethod)); + + SeparatedSyntaxList args = original.ArgumentList.Arguments; + if (argCountToKeep < args.Count) + { + args = SyntaxFactory.SeparatedList(args.Take(argCountToKeep)); + } + + return SyntaxFactory.InvocationExpression(newAccess, original.ArgumentList.WithArguments(args)); + } + + private static InvocationExpressionSyntax BuildAddEmptyFileInvocation( + InvocationExpressionSyntax original, MemberAccessExpressionSyntax memberAccess) + { + InvocationExpressionSyntax createCall = BuildSubReceiverInvocation( + original, memberAccess, "File", "Create", argCountToKeep: int.MaxValue); + + return SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + createCall, + SyntaxFactory.IdentifierName("Dispose"))); + } + + // ── Pattern: accessor.AddFile ──────────────────────────────────────────── + + private static async Task TryRegisterAddFileFixAsync( + CodeFixContext context, Diagnostic diagnostic, SyntaxNode node) + { + InvocationExpressionSyntax? invocation = node.FirstAncestorOrSelf(); + if (invocation?.Expression is not MemberAccessExpressionSyntax + || invocation.ArgumentList.Arguments.Count < 2) + { + return; + } + + SemanticModel? semanticModel = await context.Document + .GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null) + { + return; + } + + if (ClassifyMockFileDataExpression(invocation.ArgumentList.Arguments[1].Expression, semanticModel, + context.CancellationToken) is null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + Resources.TestablyAbstractionsMigration001CodeFixTitle, + ct => ApplyAddFileRewriteAsync(context.Document, diagnostic, ct), + equivalenceKey: Patterns.AccessorAddFile), + diagnostic); + } + + private static async Task ApplyAddFileRewriteAsync( + Document document, Diagnostic diagnostic, 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(); + if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess + || invocation.ArgumentList.Arguments.Count < 2) + { + return document; + } + + ArgumentSyntax pathArg = invocation.ArgumentList.Arguments[0]; + MockFileDataShape? shape = ClassifyMockFileDataExpression( + invocation.ArgumentList.Arguments[1].Expression, semanticModel, cancellationToken); + if (shape is null) + { + return document; + } + + InvocationExpressionSyntax rewritten = BuildAddFileReplacement(memberAccess, pathArg, shape.Value); + compilationUnit = compilationUnit.ReplaceNode(invocation, rewritten.WithTriviaFrom(invocation)); + return document.WithSyntaxRoot(compilationUnit); + } + + private static InvocationExpressionSyntax BuildAddFileReplacement( + MemberAccessExpressionSyntax memberAccess, ArgumentSyntax pathArg, MockFileDataShape shape) + { + string newMethod = shape.Kind switch + { + MockFileDataKind.Text => "WriteAllText", + MockFileDataKind.Bytes => "WriteAllBytes", + _ => "WriteAllText", + }; + + MemberAccessExpressionSyntax newAccess = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + memberAccess.Expression, + SyntaxFactory.IdentifierName("File")), + SyntaxFactory.IdentifierName(newMethod)); + + SeparatedSyntaxList args = SyntaxFactory.SeparatedList(new[] + { + pathArg.WithoutTrivia(), + SyntaxFactory.Argument(shape.PrimaryContent.WithoutTrivia()), + }); + if (shape.SecondaryContent is not null) + { + args = args.Add(SyntaxFactory.Argument(shape.SecondaryContent.WithoutTrivia())); + } + + return SyntaxFactory.InvocationExpression(newAccess, SyntaxFactory.ArgumentList(args)); + } + + private static MockFileDataShape? ClassifyMockFileDataExpression( + ExpressionSyntax expression, SemanticModel semanticModel, CancellationToken cancellationToken) + { + // Only a literal `new MockFileData(...)` (no initializer) is in-scope for Phase 2. + InitializerExpressionSyntax? initializer = expression switch + { + ObjectCreationExpressionSyntax oc => oc.Initializer, + ImplicitObjectCreationExpressionSyntax ic => ic.Initializer, + _ => null, + }; + ArgumentListSyntax? argList = expression switch + { + ObjectCreationExpressionSyntax oc => oc.ArgumentList, + ImplicitObjectCreationExpressionSyntax ic => ic.ArgumentList, + _ => null, + }; + if (argList is null || (initializer is not null && initializer.Expressions.Count > 0)) + { + return null; + } + + SymbolInfo info = semanticModel.GetSymbolInfo(expression, cancellationToken); + if (info.Symbol is not IMethodSymbol { MethodKind: MethodKind.Constructor, } ctor + || ctor.ContainingType is not { Name: "MockFileData", } containing + || containing.ContainingNamespace?.ToDisplayString() + != "System.IO.Abstractions.TestingHelpers") + { + return null; + } + + ImmutableArray parameters = ctor.Parameters; + if (parameters.Length == 1 + && parameters[0].Type.SpecialType == SpecialType.System_String + && argList.Arguments.Count == 1) + { + return new MockFileDataShape(MockFileDataKind.Text, argList.Arguments[0].Expression, null); + } + + if (parameters.Length == 2 + && parameters[0].Type.SpecialType == SpecialType.System_String + && parameters[1].Type is { Name: "Encoding", ContainingNamespace.Name: "Text", } + && argList.Arguments.Count == 2) + { + return new MockFileDataShape( + MockFileDataKind.Text, + argList.Arguments[0].Expression, + argList.Arguments[1].Expression); + } + + if (parameters.Length == 1 + && parameters[0].Type is IArrayTypeSymbol { ElementType.SpecialType: SpecialType.System_Byte, } + && argList.Arguments.Count == 1) + { + return new MockFileDataShape(MockFileDataKind.Bytes, argList.Arguments[0].Expression, null); + } + + // MockFileData(MockFileData template) and any other overload are out of scope here. + return null; + } + + private enum MockFileDataKind + { + Text, + Bytes, + } + + private readonly struct MockFileDataShape + { + public MockFileDataShape(MockFileDataKind kind, ExpressionSyntax primary, ExpressionSyntax? secondary) + { + Kind = kind; + PrimaryContent = primary; + SecondaryContent = secondary; + } + + public MockFileDataKind Kind { get; } + public ExpressionSyntax PrimaryContent { get; } + public ExpressionSyntax? SecondaryContent { get; } + } + + // ── Pattern: MockFileSystem.ctor(files[, options/currentDir]) ──────────── + + private static async Task TryRegisterFilesCtorFixAsync( + CodeFixContext context, Diagnostic diagnostic, SyntaxNode node, string pattern) + { + if (!TryGetCreationInLocalDecl(node, out BaseObjectCreationExpressionSyntax? creation, + out ArgumentListSyntax? argList, out _, out BlockSyntax? _)) + { + return; + } + + SemanticModel? semanticModel = await context.Document + .GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null) + { + return; + } + + if (TryParseDictionaryEntries(argList!.Arguments[0].Expression, semanticModel, + context.CancellationToken) is null) + { + return; + } + + if (argList.Arguments.Count == 2 + && TryBuildSecondCtorArgList(argList.Arguments[1], pattern) is null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + Resources.TestablyAbstractionsMigration001CodeFixTitle, + ct => ApplyFilesCtorRewriteAsync(context.Document, diagnostic, pattern, ct), + equivalenceKey: pattern), + diagnostic); + } + + private static async Task ApplyFilesCtorRewriteAsync( + Document document, Diagnostic diagnostic, 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, + out ArgumentListSyntax? argList, + out LocalDeclarationStatementSyntax? localDecl, + out BlockSyntax? block)) + { + return document; + } + + List? entries = TryParseDictionaryEntries( + argList!.Arguments[0].Expression, semanticModel, cancellationToken); + if (entries is null) + { + return document; + } + + ArgumentListSyntax newArgList = SyntaxFactory.ArgumentList(); + if (argList.Arguments.Count == 2) + { + ArgumentListSyntax? built = TryBuildSecondCtorArgList(argList.Arguments[1], pattern); + if (built is null) + { + return document; + } + + newArgList = built; + } + + BaseObjectCreationExpressionSyntax newCreation = creation! switch + { + ObjectCreationExpressionSyntax oc => oc + .WithArgumentList(newArgList.WithTriviaFrom(argList)) + .WithInitializer(null), + ImplicitObjectCreationExpressionSyntax ic => ic + .WithArgumentList(newArgList.WithTriviaFrom(argList)) + .WithInitializer(null), + _ => creation!, + }; + + LocalDeclarationStatementSyntax newDecl = localDecl!.ReplaceNode(creation!, newCreation); + + string variableName = localDecl.Declaration.Variables[0].Identifier.Text; + (string indentation, string newline) = DetectIndentationAndNewline(localDecl); + List followUps = entries + .Select(entry => BuildFollowUpStatement(variableName, entry, indentation, newline)) + .ToList(); + + SyntaxList updatedStatements = block!.Statements; + int index = updatedStatements.IndexOf(localDecl); + updatedStatements = updatedStatements.Replace(localDecl, newDecl); + updatedStatements = updatedStatements.InsertRange(index + 1, followUps); + + BlockSyntax newBlock = block.WithStatements(updatedStatements); + compilationUnit = compilationUnit.ReplaceNode(block, newBlock); + compilationUnit = SwapToTestablyUsing(compilationUnit); + + return document.WithSyntaxRoot(compilationUnit); + } + + private static bool TryGetCreationInLocalDecl( + SyntaxNode node, + out BaseObjectCreationExpressionSyntax? creation, + out ArgumentListSyntax? argList, + out LocalDeclarationStatementSyntax? localDecl, + out BlockSyntax? block) + { + creation = node.FirstAncestorOrSelf(); + argList = creation?.ArgumentList; + localDecl = null; + block = null; + + if (creation is null || argList is null || argList.Arguments.Count < 1) + { + return false; + } + + localDecl = creation.FirstAncestorOrSelf(); + if (localDecl is null + || localDecl.Declaration.Variables.Count != 1 + || localDecl.Declaration.Variables[0].Initializer?.Value != creation + || localDecl.Parent is not BlockSyntax foundBlock) + { + return false; + } + + block = foundBlock; + return true; + } + + private static ArgumentListSyntax? TryBuildSecondCtorArgList(ArgumentSyntax secondArg, string pattern) + { + if (pattern == Patterns.MockFileSystemFilesOptionsConstructor) + { + return TryBuildTestablyOptionsArgList(secondArg.Expression); + } + + // MockFileSystemFilesConstructor: second arg is `string currentDirectory`. + ExpressionSyntax expression = secondArg.Expression; + if (expression is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression) + && literal.Token.ValueText.Length == 0) + { + return SyntaxFactory.ArgumentList(); + } + + SimpleLambdaExpressionSyntax lambda = SyntaxFactory.SimpleLambdaExpression( + SyntaxFactory.Parameter(SyntaxFactory.Identifier("o")), + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("o"), + SyntaxFactory.IdentifierName("UseCurrentDirectory")), + SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(expression.WithoutTrivia()))))); + + return SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(lambda))); + } + + private static List? TryParseDictionaryEntries( + ExpressionSyntax dictExpression, SemanticModel semanticModel, CancellationToken cancellationToken) + { + InitializerExpressionSyntax? initializer = dictExpression switch + { + ObjectCreationExpressionSyntax oc => oc.Initializer, + ImplicitObjectCreationExpressionSyntax ic => ic.Initializer, + _ => null, + }; + if (initializer is null) + { + return null; + } + + List result = []; + foreach (ExpressionSyntax entryExpression in initializer.Expressions) + { + if (entryExpression is not AssignmentExpressionSyntax assignment + || assignment.Left is not ImplicitElementAccessSyntax elementAccess + || elementAccess.ArgumentList.Arguments.Count != 1) + { + return null; + } + + ArgumentSyntax keyArg = elementAccess.ArgumentList.Arguments[0]; + MockFileDataShape? shape = ClassifyMockFileDataExpression( + assignment.Right, semanticModel, cancellationToken); + if (shape is null) + { + return null; + } + + result.Add(new DictionaryEntryShape(keyArg.Expression, shape.Value)); + } + + return result; + } + + private static StatementSyntax BuildFollowUpStatement( + string receiverName, DictionaryEntryShape entry, string indentation, string newline) + { + string newMethod = entry.Value.Kind == MockFileDataKind.Bytes ? "WriteAllBytes" : "WriteAllText"; + string args = FormatArgumentList(entry.Key, entry.Value); + + // Parse the statement from text so the trivia is non-elastic — otherwise the + // Formatter rewrites both the inserted line and the enclosing block's closing + // brace, defeating the surrounding indentation style. + string text = $"{indentation}{receiverName}.File.{newMethod}({args});{newline}"; + return SyntaxFactory.ParseStatement(text); + } + + private static string FormatArgumentList(ExpressionSyntax key, MockFileDataShape shape) + { + string primary = $"{key.ToString().Trim()}, {shape.PrimaryContent.ToString().Trim()}"; + if (shape.SecondaryContent is not null) + { + return $"{primary}, {shape.SecondaryContent.ToString().Trim()}"; + } + + return primary; + } + + private static (string indentation, string newline) DetectIndentationAndNewline(SyntaxNode node) + { + SyntaxTriviaList leading = node.GetLeadingTrivia(); + System.Text.StringBuilder indent = new(); + foreach (SyntaxTrivia trivia in leading.Reverse()) + { + if (trivia.IsKind(SyntaxKind.WhitespaceTrivia)) + { + indent.Insert(0, trivia.ToString()); + } + else + { + break; + } + } + + string sourceText = node.SyntaxTree.GetText().ToString(); + string newline = sourceText.Contains("\r\n") ? "\r\n" : "\n"; + return (indent.ToString(), newline); + } + + private readonly struct DictionaryEntryShape + { + public DictionaryEntryShape(ExpressionSyntax key, MockFileDataShape value) + { + Key = key; + Value = value; + } + + public ExpressionSyntax Key { get; } + public MockFileDataShape Value { get; } + } + + // ── Shared: using-directive swap ───────────────────────────────────────── + + private static CompilationUnitSyntax SwapToTestablyUsing(CompilationUnitSyntax compilationUnit) + { UsingDirectiveSyntax? testingHelpersUsing = compilationUnit.Usings .FirstOrDefault(u => u.Name?.ToString() == TestingHelpersNamespace); bool hasTestablyUsing = compilationUnit.Usings @@ -71,8 +770,6 @@ private static async Task RewriteUsingsAsync(Document document, Cancel if (testingHelpersUsing is not null) { - // Replace in place so the rewrite inherits the original trivia (line endings, - // indentation, leading comments) rather than depending on a fresh syntax token. if (hasTestablyUsing) { compilationUnit = @@ -88,16 +785,11 @@ private static async Task RewriteUsingsAsync(Document document, Cancel } else if (!hasTestablyUsing) { - compilationUnit = AppendUsing(compilationUnit, TestablyTestingNamespace); + UsingDirectiveSyntax usingDirective = BuildUsingDirective(compilationUnit, TestablyTestingNamespace); + compilationUnit = compilationUnit.AddUsings(usingDirective); } - return document.WithSyntaxRoot(compilationUnit); - } - - private static CompilationUnitSyntax AppendUsing(CompilationUnitSyntax compilationUnit, string namespaceName) - { - UsingDirectiveSyntax usingDirective = BuildUsingDirective(compilationUnit, namespaceName); - return compilationUnit.AddUsings(usingDirective); + return compilationUnit; } private static UsingDirectiveSyntax BuildUsingDirective(CompilationUnitSyntax compilationUnit, string namespaceName) diff --git a/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs b/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs index 1040d9b..9f54017 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs @@ -13,6 +13,41 @@ public static class Patterns /// public const string Key = "pattern"; + // ── Constructors ────────────────────────────────────────────────────── + /// The parameterless new MockFileSystem() constructor. public const string MockFileSystemDefaultConstructor = "MockFileSystem.ctor()"; + + /// + /// new MockFileSystem(IDictionary<string, MockFileData> files, string currentDirectory = ""). + /// + public const string MockFileSystemFilesConstructor = "MockFileSystem.ctor(files)"; + + /// new MockFileSystem(MockFileSystemOptions options). + public const string MockFileSystemOptionsConstructor = "MockFileSystem.ctor(options)"; + + /// + /// new MockFileSystem(IDictionary<string, MockFileData> files, MockFileSystemOptions options). + /// + public const string MockFileSystemFilesOptionsConstructor = "MockFileSystem.ctor(files,options)"; + + // ── IMockFileDataAccessor methods on MockFileSystem ─────────────────── + + /// accessor.AddFile(path, mockFileData[, verifyAccess]). + public const string AccessorAddFile = "accessor.AddFile"; + + /// accessor.AddEmptyFile(path). + public const string AccessorAddEmptyFile = "accessor.AddEmptyFile"; + + /// accessor.AddDirectory(path). + public const string AccessorAddDirectory = "accessor.AddDirectory"; + + /// accessor.RemoveFile(path[, verifyAccess]). + public const string AccessorRemoveFile = "accessor.RemoveFile"; + + /// accessor.MoveDirectory(sourcePath, destPath). + public const string AccessorMoveDirectory = "accessor.MoveDirectory"; + + /// accessor.FileExists(path). + public const string AccessorFileExists = "accessor.FileExists"; } diff --git a/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs b/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs index 549b0d7..986ea80 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs @@ -36,6 +36,10 @@ public override void Initialize(AnalysisContext context) start.RegisterOperationAction( ctx => AnalyzeObjectCreation(ctx, symbols), OperationKind.ObjectCreation); + + start.RegisterOperationAction( + ctx => AnalyzeInvocation(ctx, symbols), + OperationKind.Invocation); }); } @@ -46,29 +50,95 @@ private static void AnalyzeObjectCreation(OperationAnalysisContext context, Test return; } - INamedTypeSymbol? type = creation.Constructor?.ContainingType; - if (type is null) + IMethodSymbol? constructor = creation.Constructor; + if (constructor is null + || !SymbolEqualityComparer.Default.Equals(constructor.ContainingType, symbols.MockFileSystem)) { return; } - // Phase 1 only handles the parameterless overload. The other three - // MockFileSystem constructors are addressed in Phase 2. - if (SymbolEqualityComparer.Default.Equals(type, symbols.MockFileSystem) - && creation.Arguments.Length == 0) + string? pattern = ClassifyMockFileSystemConstructor(constructor); + if (pattern is not null) { - Report(context, creation, Patterns.MockFileSystemDefaultConstructor); + Report(context, creation.Syntax.GetLocation(), pattern); } } - private static void Report(OperationAnalysisContext context, IObjectCreationOperation creation, string pattern) + private static void AnalyzeInvocation(OperationAnalysisContext context, TestableIoSymbols symbols) + { + if (context.Operation is not IInvocationOperation invocation) + { + return; + } + + IMethodSymbol method = invocation.TargetMethod; + INamedTypeSymbol? containingType = method.ContainingType; + if (containingType is null) + { + return; + } + + bool onMockFileSystem = + SymbolEqualityComparer.Default.Equals(containingType, symbols.MockFileSystem); + bool onAccessor = + symbols.MockFileDataAccessor is { } accessor + && SymbolEqualityComparer.Default.Equals(containingType, accessor); + if (!onMockFileSystem && !onAccessor) + { + return; + } + + string? pattern = ClassifyAccessorMethod(method); + if (pattern is not null) + { + Report(context, invocation.Syntax.GetLocation(), pattern); + } + } + + private static string? ClassifyMockFileSystemConstructor(IMethodSymbol constructor) + { + ImmutableArray parameters = constructor.Parameters; + return parameters.Length switch + { + 0 => Patterns.MockFileSystemDefaultConstructor, + 1 when IsMockFileSystemOptions(parameters[0]) => Patterns.MockFileSystemOptionsConstructor, + 2 when IsFilesDictionary(parameters[0]) && parameters[1].Type.SpecialType == SpecialType.System_String + => Patterns.MockFileSystemFilesConstructor, + 2 when IsFilesDictionary(parameters[0]) && IsMockFileSystemOptions(parameters[1]) + => Patterns.MockFileSystemFilesOptionsConstructor, + _ => null, + }; + } + + private static bool IsFilesDictionary(IParameterSymbol parameter) + => parameter.Type is INamedTypeSymbol named + && named.Name == "IDictionary" + && named.ContainingNamespace?.ToDisplayString() == "System.Collections.Generic"; + + private static bool IsMockFileSystemOptions(IParameterSymbol parameter) + => parameter.Type is INamedTypeSymbol named + && named.Name == "MockFileSystemOptions" + && named.ContainingNamespace?.ToDisplayString() == TestableIoSymbols.TestingHelpersNamespace; + + private static string? ClassifyAccessorMethod(IMethodSymbol method) => method.Name switch + { + "AddFile" => Patterns.AccessorAddFile, + "AddEmptyFile" => Patterns.AccessorAddEmptyFile, + "AddDirectory" => Patterns.AccessorAddDirectory, + "RemoveFile" => Patterns.AccessorRemoveFile, + "MoveDirectory" => Patterns.AccessorMoveDirectory, + "FileExists" => Patterns.AccessorFileExists, + _ => null, + }; + + private static void Report(OperationAnalysisContext context, Location location, string pattern) { ImmutableDictionary properties = new Dictionary { [Patterns.Key] = pattern, }.ToImmutableDictionary(); context.ReportDiagnostic(Diagnostic.Create( Rules.SystemIOAbstractionsRule, - creation.Syntax.GetLocation(), + location, properties, messageArgs: null)); } diff --git a/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/MockFileSystemSamples.cs b/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/MockFileSystemSamples.cs index 8f70ecd..46fd4e1 100644 --- a/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/MockFileSystemSamples.cs +++ b/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/MockFileSystemSamples.cs @@ -4,22 +4,72 @@ // is suppressed here to keep static-analysis dashboards quiet. #pragma warning disable TestablyAbstractionsMigration001 +using System.Text; using System.IO.Abstractions.TestingHelpers; namespace Testably.Abstractions.Migration.SystemIOAbstractionsPlayground; /// /// Sample call sites that exercise the System.IO.Abstractions.TestingHelpers API. -/// The analyzer in this solution should flag the supported patterns here so that the -/// accompanying code fix can be used to migrate them to Testably.Abstractions.Testing. +/// The analyzer in this solution flags every supported pattern here so the accompanying +/// code fix can be exercised against the file via the parity check. /// public class MockFileSystemSamples { - // Phase 1: parameterless `new MockFileSystem()`. The analyzer flags this; the code fix - // rewrites the file's usings to bind `MockFileSystem` to the Testably namespace. + // Phase 1: parameterless `new MockFileSystem()`. public static IFileSystem Parameterless() { MockFileSystem fileSystem = new(); return fileSystem; } + + // Phase 2: MockFileSystem(IDictionary) — dictionary-of-files + // constructor, expanded statement-by-statement at the call site. + public static void FilesConstructor() + { + var fs = new MockFileSystem(new Dictionary + { + ["/etc/hosts"] = new MockFileData("127.0.0.1 localhost"), + }); + } + + // Phase 2: MockFileSystem(IDictionary, string currentDirectory) — folded into a + // UseCurrentDirectory options lambda. + public static void FilesConstructorWithCurrentDirectory() + { + var fs = new MockFileSystem(new Dictionary + { + ["/etc/hosts"] = new MockFileData("127.0.0.1 localhost"), + }, "/work"); + } + + // Phase 2: MockFileSystem(MockFileSystemOptions) — literal options with + // CurrentDirectory translates to an options lambda. + public static IFileSystem OptionsConstructor() + { + return new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = "/work" }); + } + + // Phase 2: MockFileSystem(IDictionary, MockFileSystemOptions) — both folded. + public static void FilesOptionsConstructor(byte[] bytes) + { + var fs = new MockFileSystem(new Dictionary + { + ["/etc/hosts"] = new MockFileData("127.0.0.1 localhost"), + ["/etc/binary"] = new MockFileData(bytes), + ["/etc/utf8"] = new MockFileData("encoded", Encoding.UTF8), + }, new MockFileSystemOptions { CurrentDirectory = "/work" }); + } + + // Phase 2: 1:1 accessor methods on MockFileSystem (IMockFileDataAccessor surface). + public static bool AccessorMethods(MockFileSystem fs) + { + fs.AddDirectory("/a"); + fs.AddFile("/a/text.txt", new MockFileData("hello")); + fs.AddFile("/a/bytes.bin", new MockFileData(new byte[] { 0x01, 0x02, })); + fs.AddEmptyFile("/a/empty.txt"); + fs.MoveDirectory("/a", "/b"); + fs.RemoveFile("/b/text.txt"); + return fs.FileExists("/b/bytes.bin"); + } } diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs index 1567aa5..9abb41d 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs @@ -26,7 +26,7 @@ await Verifier.VerifyAnalyzerAsync( } [Fact] - public async Task ConstructorWithArguments_ShouldNotBeFlagged_InPhase1() + public async Task FilesConstructor_ShouldBeFlagged() { const string source = """ using System.Collections.Generic; @@ -36,11 +36,92 @@ public async Task ConstructorWithArguments_ShouldNotBeFlagged_InPhase1() public class C { public IFileSystem Build() - => new MockFileSystem(new Dictionary()); + => {|#0:new MockFileSystem(new Dictionary())|}; } """; - await Verifier.VerifyAnalyzerAsync(source); + await Verifier.VerifyAnalyzerAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); + } + + [Fact] + public async Task OptionsConstructor_ShouldBeFlagged() + { + const string source = """ + using System.IO.Abstractions; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public IFileSystem Build() + => {|#0:new MockFileSystem(new MockFileSystemOptions())|}; + } + """; + + await Verifier.VerifyAnalyzerAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); + } + + [Fact] + public async Task FilesOptionsConstructor_ShouldBeFlagged() + { + const string source = """ + using System.Collections.Generic; + using System.IO.Abstractions; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public IFileSystem Build() + => {|#0:new MockFileSystem(new Dictionary(), new MockFileSystemOptions())|}; + } + """; + + await Verifier.VerifyAnalyzerAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); + } + + [Theory] + [InlineData("AddDirectory(\"/foo\")")] + [InlineData("AddEmptyFile(\"/foo\")")] + [InlineData("RemoveFile(\"/foo\")")] + [InlineData("MoveDirectory(\"/foo\", \"/bar\")")] + [InlineData("FileExists(\"/foo\")")] + public async Task AccessorMethods_ShouldBeFlagged(string invocation) + { + string source = $$""" + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) => {|#0:fs.{{invocation}}|}; + } + """; + + await Verifier.VerifyAnalyzerAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); + } + + [Fact] + public async Task AddFile_ShouldBeFlagged() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => {|#0:fs.AddFile("/foo", new MockFileData("x"))|}; + } + """; + + await Verifier.VerifyAnalyzerAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); } [Fact] diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.cs index b399764..68203f8 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.cs @@ -71,4 +71,498 @@ await Verifier.VerifyCodeFixAsync( Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), fixedSource); } + + [Fact] + public async Task OptionsConstructor_EmptyInitializer_ShouldRewriteToParameterless() + { + const string source = """ + using System.IO.Abstractions; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public IFileSystem Build() + => {|#0:new MockFileSystem(new MockFileSystemOptions())|}; + } + """; + + const string fixedSource = """ + using System.IO.Abstractions; + using Testably.Abstractions.Testing; + + public class C + { + public IFileSystem Build() + => new MockFileSystem(); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task OptionsConstructor_CurrentDirectory_ShouldRewriteToUseCurrentDirectoryLambda() + { + const string source = """ + using System.IO.Abstractions; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public IFileSystem Build() + => {|#0:new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = "/work" })|}; + } + """; + + const string fixedSource = """ + using System.IO.Abstractions; + using Testably.Abstractions.Testing; + + public class C + { + public IFileSystem Build() + => new MockFileSystem(o => o.UseCurrentDirectory("/work")); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task OptionsConstructor_UnsupportedProperty_HasNoFix() + { + // The analyzer still flags the call site, but no code action is registered + // (CreateDefaultTempDir has no Testably equivalent). The user must address it + // manually, so the source must be identical before and after. + const string source = """ + using System.IO.Abstractions; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public IFileSystem Build() + => {|#0:new MockFileSystem(new MockFileSystemOptions { CreateDefaultTempDir = false })|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } + + [Fact] + public async Task AccessorAddDirectory_ShouldRewriteToDirectoryCreateDirectory() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) => {|#0:fs.AddDirectory("/foo")|}; + } + """; + + const string fixedSource = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) => fs.Directory.CreateDirectory("/foo"); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AccessorRemoveFile_ShouldRewriteToFileDelete_AndDropVerifyAccess() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) => {|#0:fs.RemoveFile("/foo", false)|}; + } + """; + + const string fixedSource = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) => fs.File.Delete("/foo"); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AccessorMoveDirectory_ShouldRewriteToDirectoryMove() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) => {|#0:fs.MoveDirectory("/a", "/b")|}; + } + """; + + const string fixedSource = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) => fs.Directory.Move("/a", "/b"); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AccessorFileExists_ShouldRewriteToFileExists() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public bool Run(MockFileSystem fs) => {|#0:fs.FileExists("/foo")|}; + } + """; + + const string fixedSource = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public bool Run(MockFileSystem fs) => fs.File.Exists("/foo"); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AccessorAddFile_TextContent_ShouldRewriteToFileWriteAllText() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => {|#0:fs.AddFile("/foo", new MockFileData("hello"))|}; + } + """; + + const string fixedSource = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => fs.File.WriteAllText("/foo", "hello"); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AccessorAddFile_TextContentWithEncoding_ShouldRewriteToFileWriteAllText() + { + const string source = """ + using System.Text; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => {|#0:fs.AddFile("/foo", new MockFileData("hello", Encoding.UTF8))|}; + } + """; + + const string fixedSource = """ + using System.Text; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => fs.File.WriteAllText("/foo", "hello", Encoding.UTF8); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AccessorAddFile_ByteContent_ShouldRewriteToFileWriteAllBytes() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs, byte[] bytes) + => {|#0:fs.AddFile("/foo", new MockFileData(bytes))|}; + } + """; + + const string fixedSource = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs, byte[] bytes) + => fs.File.WriteAllBytes("/foo", bytes); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AccessorAddFile_WithInitializer_HasNoFix() + { + // MockFileData with extra property initializers is a Phase 4 manual-review case. + const string source = """ + using System.IO; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => {|#0:fs.AddFile("/foo", new MockFileData("x") { Attributes = FileAttributes.ReadOnly })|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } + + [Fact] + public async Task AccessorAddFile_NonLiteralMockFileData_HasNoFix() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs, MockFileData data) + => {|#0:fs.AddFile("/foo", data)|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } + + [Fact] + public async Task FilesConstructor_LocalDecl_ShouldExpandToParameterlessCtorAndWrites() + { + const string source = """ + using System.Collections.Generic; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run() + { + var fs = {|#0:new MockFileSystem(new Dictionary + { + ["/foo"] = new MockFileData("hello"), + })|}; + } + } + """; + + const string fixedSource = """ + using System.Collections.Generic; + using Testably.Abstractions.Testing; + + public class C + { + public void Run() + { + var fs = new MockFileSystem(); + fs.File.WriteAllText("/foo", "hello"); + } + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task FilesConstructor_WithCurrentDirectoryString_ShouldFoldIntoOptionsLambda() + { + const string source = """ + using System.Collections.Generic; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run() + { + var fs = {|#0:new MockFileSystem(new Dictionary + { + ["/foo"] = new MockFileData("hello"), + }, "/work")|}; + } + } + """; + + const string fixedSource = """ + using System.Collections.Generic; + using Testably.Abstractions.Testing; + + public class C + { + public void Run() + { + var fs = new MockFileSystem(o => o.UseCurrentDirectory("/work")); + fs.File.WriteAllText("/foo", "hello"); + } + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task FilesOptionsConstructor_ShouldFoldOptionsAndExpandEntries() + { + const string source = """ + using System.Collections.Generic; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(byte[] bytes) + { + var fs = {|#0:new MockFileSystem(new Dictionary + { + ["/a"] = new MockFileData("hello"), + ["/b"] = new MockFileData(bytes), + }, new MockFileSystemOptions { CurrentDirectory = "/work" })|}; + } + } + """; + + const string fixedSource = """ + using System.Collections.Generic; + using Testably.Abstractions.Testing; + + public class C + { + public void Run(byte[] bytes) + { + var fs = new MockFileSystem(o => o.UseCurrentDirectory("/work")); + fs.File.WriteAllText("/a", "hello"); + fs.File.WriteAllBytes("/b", bytes); + } + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task FilesConstructor_ExpressionBodyContext_HasNoFix() + { + // The construction is not in a local-declaration statement, so the expansion + // would have nowhere to place the follow-up File.Write* statements. + const string source = """ + using System.Collections.Generic; + using System.IO.Abstractions; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public IFileSystem Build() + => {|#0:new MockFileSystem(new Dictionary + { + ["/foo"] = new MockFileData("hello"), + })|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } + + [Fact] + public async Task AccessorAddEmptyFile_ShouldRewriteToFileCreateDispose() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) => {|#0:fs.AddEmptyFile("/foo")|}; + } + """; + + const string fixedSource = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) => fs.File.Create("/foo").Dispose(); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } } From a0265b29d383cab148934a2dbef86e6bc2acbf31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 14 May 2026 20:06:25 +0200 Subject: [PATCH 2/3] Fix review issues --- .../SystemIOAbstractionsCodeFixProvider.cs | 136 +++++++++++++----- ...ystemIOAbstractionsCodeFixProviderTests.cs | 111 ++++++++++++++ 2 files changed, 214 insertions(+), 33 deletions(-) diff --git a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs index bfb74d2..501fb05 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs @@ -57,7 +57,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) switch (pattern) { case Patterns.MockFileSystemDefaultConstructor: - RegisterDefaultCtorFix(context, diagnostic); + TryRegisterDefaultCtorFix(context, diagnostic, node); break; case Patterns.MockFileSystemOptionsConstructor: TryRegisterOptionsCtorFix(context, diagnostic, node); @@ -67,7 +67,8 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) case Patterns.AccessorMoveDirectory: case Patterns.AccessorFileExists: case Patterns.AccessorAddEmptyFile: - TryRegisterAccessorMethodFix(context, diagnostic, node, pattern); + await TryRegisterAccessorMethodFixAsync(context, diagnostic, node, pattern) + .ConfigureAwait(false); break; case Patterns.AccessorAddFile: await TryRegisterAddFileFixAsync(context, diagnostic, node).ConfigureAwait(false); @@ -83,8 +84,19 @@ await TryRegisterFilesCtorFixAsync(context, diagnostic, node, pattern) // ── Pattern: MockFileSystem.ctor() ─────────────────────────────────────── - private static void RegisterDefaultCtorFix(CodeFixContext context, Diagnostic diagnostic) + private static void TryRegisterDefaultCtorFix(CodeFixContext context, Diagnostic diagnostic, SyntaxNode node) { + // The fix only adjusts using directives. If the construction is alias- or + // fully-qualified (`new TestableIo.MockFileSystem()`), the type identifier + // stays bound to TestableIO regardless of the using swap, so the rewrite + // would produce code that still targets the old library. + BaseObjectCreationExpressionSyntax? creation = + node.FirstAncestorOrSelf(); + if (creation is null || !HasUnqualifiedMockFileSystemTypeName(creation)) + { + return; + } + context.RegisterCodeFix( CodeAction.Create( Resources.TestablyAbstractionsMigration001CodeFixTitle, @@ -93,6 +105,14 @@ private static void RegisterDefaultCtorFix(CodeFixContext context, Diagnostic di diagnostic); } + private static bool HasUnqualifiedMockFileSystemTypeName(BaseObjectCreationExpressionSyntax creation) + => creation switch + { + ImplicitObjectCreationExpressionSyntax => true, + ObjectCreationExpressionSyntax { Type: IdentifierNameSyntax, } => true, + _ => false, + }; + private static async Task RewriteUsingsAsync(Document document, CancellationToken cancellationToken) { SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); @@ -110,7 +130,8 @@ private static async Task RewriteUsingsAsync(Document document, Cancel private static void TryRegisterOptionsCtorFix(CodeFixContext context, Diagnostic diagnostic, SyntaxNode node) { ObjectCreationExpressionSyntax? creation = node.FirstAncestorOrSelf(); - if (creation?.ArgumentList is not { Arguments.Count: 1, } argList) + if (creation?.ArgumentList is not { Arguments.Count: 1, } argList + || !HasUnqualifiedMockFileSystemTypeName(creation)) { return; } @@ -204,27 +225,69 @@ optionsExpression is ObjectCreationExpressionSyntax return SyntaxFactory.ArgumentList(); } - SimpleLambdaExpressionSyntax lambda = SyntaxFactory.SimpleLambdaExpression( - SyntaxFactory.Parameter(SyntaxFactory.Identifier("o")), + return SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(BuildUseCurrentDirectoryLambda(currentDirectoryRhs)))); + } + + private static SimpleLambdaExpressionSyntax BuildUseCurrentDirectoryLambda(ExpressionSyntax currentDirectory) + { + // Avoid shadowing identifiers used inside the captured `currentDirectory` + // expression. `new MockFileSystemOptions { CurrentDirectory = o }` must not + // rewrite to `o => o.UseCurrentDirectory(o)`. + string parameterName = PickFreshLambdaParameterName(currentDirectory); + return SyntaxFactory.SimpleLambdaExpression( + SyntaxFactory.Parameter(SyntaxFactory.Identifier(parameterName)), SyntaxFactory.InvocationExpression( SyntaxFactory.MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, - SyntaxFactory.IdentifierName("o"), + SyntaxFactory.IdentifierName(parameterName), SyntaxFactory.IdentifierName("UseCurrentDirectory")), SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.Argument(currentDirectoryRhs.WithoutTrivia()))))); + SyntaxFactory.Argument(currentDirectory.WithoutTrivia()))))); + } - return SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.Argument(lambda))); + private static string PickFreshLambdaParameterName(ExpressionSyntax embedded) + { + HashSet used = new(embedded.DescendantNodesAndSelf() + .OfType() + .Select(id => id.Identifier.Text)); + + string[] candidates = ["o", "options", "opt", "builder",]; + foreach (string candidate in candidates) + { + if (!used.Contains(candidate)) + { + return candidate; + } + } + + for (int i = 1;; i++) + { + string n = $"o{i}"; + if (!used.Contains(n)) + { + return n; + } + } } // ── Pattern: 1:1 IMockFileDataAccessor method rewrites ─────────────────── - private static void TryRegisterAccessorMethodFix( + private static async Task TryRegisterAccessorMethodFixAsync( CodeFixContext context, Diagnostic diagnostic, SyntaxNode node, string pattern) { InvocationExpressionSyntax? invocation = node.FirstAncestorOrSelf(); - if (invocation?.Expression is not MemberAccessExpressionSyntax) + if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess) + { + return; + } + + // The rewrite emits `.File.X(...)` / `.Directory.X(...)`. Those + // members live on the concrete `MockFileSystem` class, not on `IMockFileDataAccessor` — + // so the fix must not run when the user calls through the interface. + SemanticModel? semanticModel = await context.Document + .GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null || !IsConcreteMockFileSystemReceiver(memberAccess.Expression, semanticModel)) { return; } @@ -237,6 +300,15 @@ private static void TryRegisterAccessorMethodFix( diagnostic); } + private static bool IsConcreteMockFileSystemReceiver(ExpressionSyntax receiver, SemanticModel semanticModel) + { + ITypeSymbol? type = semanticModel.GetTypeInfo(receiver).Type; + return type is INamedTypeSymbol named + && named.Name == "MockFileSystem" + && named.ContainingNamespace?.ToDisplayString() + == "System.IO.Abstractions.TestingHelpers"; + } + private static async Task ApplyAccessorMethodRewriteAsync( Document document, Diagnostic diagnostic, string pattern, CancellationToken cancellationToken) { @@ -292,12 +364,14 @@ private static InvocationExpressionSyntax BuildSubReceiverInvocation( SyntaxFactory.IdentifierName(newMethod)); SeparatedSyntaxList args = original.ArgumentList.Arguments; - if (argCountToKeep < args.Count) - { - args = SyntaxFactory.SeparatedList(args.Take(argCountToKeep)); - } - - return SyntaxFactory.InvocationExpression(newAccess, original.ArgumentList.WithArguments(args)); + int keep = argCountToKeep < args.Count ? argCountToKeep : args.Count; + // Strip the NameColon: TestableIO and Testably use different parameter names + // (e.g. `MoveDirectory(sourcePath:, destPath:)` vs `Directory.Move(sourceDirName:, + // destDirName:)`). Positional binding is the only safe form across the swap. + SeparatedSyntaxList normalized = SyntaxFactory.SeparatedList( + args.Take(keep).Select(arg => arg.WithNameColon(null))); + + return SyntaxFactory.InvocationExpression(newAccess, original.ArgumentList.WithArguments(normalized)); } private static InvocationExpressionSyntax BuildAddEmptyFileInvocation( @@ -319,7 +393,7 @@ private static async Task TryRegisterAddFileFixAsync( CodeFixContext context, Diagnostic diagnostic, SyntaxNode node) { InvocationExpressionSyntax? invocation = node.FirstAncestorOrSelf(); - if (invocation?.Expression is not MemberAccessExpressionSyntax + if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess || invocation.ArgumentList.Arguments.Count < 2) { return; @@ -327,7 +401,8 @@ private static async Task TryRegisterAddFileFixAsync( SemanticModel? semanticModel = await context.Document .GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); - if (semanticModel is null) + if (semanticModel is null + || !IsConcreteMockFileSystemReceiver(memberAccess.Expression, semanticModel)) { return; } @@ -400,9 +475,12 @@ private static InvocationExpressionSyntax BuildAddFileReplacement( SyntaxFactory.IdentifierName("File")), SyntaxFactory.IdentifierName(newMethod)); + // Strip NameColon on the path arg — AddFile uses `path:` but File.WriteAllText + // uses the same name today, however the WriteAllBytes overload may differ in + // future updates. Positional binding is the safe baseline. SeparatedSyntaxList args = SyntaxFactory.SeparatedList(new[] { - pathArg.WithoutTrivia(), + pathArg.WithNameColon(null).WithoutTrivia(), SyntaxFactory.Argument(shape.PrimaryContent.WithoutTrivia()), }); if (shape.SecondaryContent is not null) @@ -499,7 +577,8 @@ private static async Task TryRegisterFilesCtorFixAsync( CodeFixContext context, Diagnostic diagnostic, SyntaxNode node, string pattern) { if (!TryGetCreationInLocalDecl(node, out BaseObjectCreationExpressionSyntax? creation, - out ArgumentListSyntax? argList, out _, out BlockSyntax? _)) + out ArgumentListSyntax? argList, out _, out BlockSyntax? _) + || !HasUnqualifiedMockFileSystemTypeName(creation!)) { return; } @@ -651,17 +730,8 @@ private static bool TryGetCreationInLocalDecl( return SyntaxFactory.ArgumentList(); } - SimpleLambdaExpressionSyntax lambda = SyntaxFactory.SimpleLambdaExpression( - SyntaxFactory.Parameter(SyntaxFactory.Identifier("o")), - SyntaxFactory.InvocationExpression( - SyntaxFactory.MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - SyntaxFactory.IdentifierName("o"), - SyntaxFactory.IdentifierName("UseCurrentDirectory")), - SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.Argument(expression.WithoutTrivia()))))); - - return SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(lambda))); + return SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(BuildUseCurrentDirectoryLambda(expression)))); } private static List? TryParseDictionaryEntries( diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.cs index 68203f8..1294346 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.cs @@ -565,4 +565,115 @@ await Verifier.VerifyCodeFixAsync( Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), fixedSource); } + + [Fact] + public async Task OptionsConstructor_CurrentDirectoryReferencesIdentifierNamed_o_ShouldPickFreshLambdaParameter() + { + // The default lambda parameter name `o` would shadow the local `o`, rewriting + // `CurrentDirectory = o` to `o => o.UseCurrentDirectory(o)` — wrong semantics. + const string source = """ + using System.IO.Abstractions; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public IFileSystem Build() + { + string o = "/work"; + return {|#0:new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = o })|}; + } + } + """; + + const string fixedSource = """ + using System.IO.Abstractions; + using Testably.Abstractions.Testing; + + public class C + { + public IFileSystem Build() + { + string o = "/work"; + return new MockFileSystem(options => options.UseCurrentDirectory(o)); + } + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AccessorMoveDirectory_WithNamedArgs_ShouldStripNameColons() + { + // TestableIO uses sourcePath/destPath; Directory.Move uses sourceDirName/destDirName. + // Keeping the labels would produce code that won't compile. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => {|#0:fs.MoveDirectory(sourcePath: "/a", destPath: "/b")|}; + } + """; + + const string fixedSource = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => fs.Directory.Move("/a", "/b"); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task ParameterlessConstructor_AliasQualifiedType_HasNoFix() + { + // `using TestableIo = …;` keeps the type alias-qualified. The using-only fix + // would not retarget the binding, so we suppress it. + const string source = """ + using System.IO.Abstractions; + using TestableIo = System.IO.Abstractions.TestingHelpers; + + public class C + { + public IFileSystem Build() => {|#0:new TestableIo.MockFileSystem()|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } + + [Fact] + public async Task AccessorAddDirectory_InterfaceTypedReceiver_HasNoFix() + { + // The rewrite would emit `accessor.Directory.CreateDirectory(...)`, but + // IMockFileDataAccessor doesn't expose a Directory property — non-compiling. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(IMockFileDataAccessor accessor) => {|#0:accessor.AddDirectory("/foo")|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } } From d252365c032afac4a087ebde68ec9344189e6f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 14 May 2026 20:21:52 +0200 Subject: [PATCH 3/3] fix: gate target-typed new() ctor fix on unqualified target type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HasUnqualifiedMockFileSystemTypeName` previously approved every `ImplicitObjectCreationExpressionSyntax`. But `new()` is target-typed: an enclosing fully-qualified or alias-qualified context (`System.IO.Abstractions.TestingHelpers.MockFileSystem fs = new();`) keeps the construction bound to TestableIO even after the using swap, so the fix would leave the source half-rewritten. Narrow the implicit case to the one shape we can verify is safe — the target type annotation on the enclosing variable declaration is itself `IdentifierNameSyntax`. Other target-typing contexts (parameters, returns, assignments to non-local LHS, casts) fall through to manual review. Add a regression test pinning the qualified-LHS no-fix path. --- .../SystemIOAbstractionsCodeFixProvider.cs | 27 ++++++++++++++++++- ...ystemIOAbstractionsCodeFixProviderTests.cs | 22 +++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs index 501fb05..5934dfd 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs @@ -108,11 +108,36 @@ private static void TryRegisterDefaultCtorFix(CodeFixContext context, Diagnostic private static bool HasUnqualifiedMockFileSystemTypeName(BaseObjectCreationExpressionSyntax creation) => creation switch { - ImplicitObjectCreationExpressionSyntax => true, ObjectCreationExpressionSyntax { Type: IdentifierNameSyntax, } => true, + ImplicitObjectCreationExpressionSyntax implicitCreation + => HasUnqualifiedImplicitTargetType(implicitCreation), _ => false, }; + private static bool HasUnqualifiedImplicitTargetType(ImplicitObjectCreationExpressionSyntax implicitCreation) + { + // `new()` is target-typed: the contextual type is what the compiler binds to. + // The using-swap fix only retargets unqualified `MockFileSystem` identifiers, + // so an enclosing fully-qualified or alias-qualified target type + // (e.g. `System.IO.Abstractions.TestingHelpers.MockFileSystem fs = new();`) + // would keep the construction bound to TestableIO regardless of the swap. + // + // Only support contexts where the syntactic target type annotation is itself + // an unqualified IdentifierNameSyntax. Other target-typing contexts (parameters, + // returns, assignments to non-local LHS, casts) fall through to manual review. + return implicitCreation.Parent switch + { + EqualsValueClauseSyntax + { + Parent: VariableDeclaratorSyntax + { + Parent: VariableDeclarationSyntax { Type: IdentifierNameSyntax, }, + }, + } => true, + _ => false, + }; + } + private static async Task RewriteUsingsAsync(Document document, CancellationToken cancellationToken) { SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.cs index 1294346..dd0413b 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.cs @@ -676,4 +676,26 @@ await Verifier.VerifyCodeFixAsync( Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), source); } + + [Fact] + public async Task ParameterlessConstructor_TargetTypedNewWithQualifiedTarget_HasNoFix() + { + // `new()` is target-typed; the qualified LHS keeps the construction bound to + // TestableIO regardless of the using swap. Suppress the fix to avoid leaving + // the source half-rewritten. + const string source = """ + public class C + { + public void Run() + { + System.IO.Abstractions.TestingHelpers.MockFileSystem fs = {|#0:new()|}; + } + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } }