From 26107f93fe4183180641407fe404b0600ba82b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 15 May 2026 08:50:10 +0200 Subject: [PATCH 1/2] feat: Phase 5.4 migrate MockFileSystem.AddDrive to WithDrive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites `fs.AddDrive(name, new MockDriveData { TotalSize = X, IsReady = Y, ... })` to `fs.WithDrive(name, d => d.SetTotalSize(X).SetIsReady(Y)...)`. Mappable initializer properties (1:1 with IStorageDrive setters): - TotalSize -> SetTotalSize - IsReady -> SetIsReady - DriveFormat -> SetDriveFormat - DriveType -> SetDriveType AvailableFreeSpace, TotalFreeSpace and VolumeLabel have no IStorageDrive setter equivalent — the rewrite bails for any initializer that touches them so the user can address them by hand. Gating: - Receiver must be the concrete TestableIO MockFileSystem (the rewrite emits WithDrive, which is Testably-only). - Second argument must be a `new MockDriveData()` (explicit or target- typed `new()`) with no ctor arguments — the copy ctor and captured references fall through to manual review. Other notes: - Empty initializer collapses to the single-argument `fs.WithDrive(name)` overload (no lambda). - Lambda parameter name is chosen fresh from ["d", "drive", "driveBuilder", d1, d2, ...] to avoid shadowing identifiers in the initializer RHS. - The fix swaps the using directive to Testably.Abstractions.Testing, matching the constructor-fix pattern, because WithDrive doesn't exist on TestableIO. Playground gains two AddDrive parity samples; analyzer + code-fix tests cover empty/single/multi-property/target-typed rewrites and the four HasNoFix cases (interface receiver, non-literal data, unsupported initializer property, copy ctor). --- .../SystemIOAbstractionsCodeFixProvider.cs | 215 +++++++++++++++ .../Patterns.cs | 6 + .../SystemIOAbstractionsAnalyzer.cs | 11 +- .../AccessorMethodTests.cs | 35 +++ .../SystemIOAbstractionsAnalyzerTests.cs | 18 ++ ...odeFixProviderTests.AccessorMethodTests.cs | 249 ++++++++++++++++++ 6 files changed, 531 insertions(+), 3 deletions(-) diff --git a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs index d1b5244..991e234 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs @@ -89,6 +89,10 @@ await TryRegisterFilesCtorFixAsync(context, diagnostic, node, pattern) case Patterns.MockFileDataPropertyWrite: TryRegisterPropertyWriteFix(context, diagnostic, node); break; + case Patterns.MockFileSystemAddDrive: + await TryRegisterAddDriveFixAsync(context, diagnostic, node) + .ConfigureAwait(false); + break; } } } @@ -1229,6 +1233,217 @@ private static bool TryMatchOneShotGetFileWrite( _ => null, }; + // ── Pattern: MockFileSystem.AddDrive ───────────────────────────────────── + + private static async Task TryRegisterAddDriveFixAsync( + CodeFixContext context, Diagnostic diagnostic, SyntaxNode node) + { + InvocationExpressionSyntax? invocation = node.FirstAncestorOrSelf(); + if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess + || invocation.ArgumentList.Arguments.Count != 2) + { + return; + } + + // The rewrite emits `.WithDrive(...)`. WithDrive is Testably-only, so + // we must swap the using as part of the fix. Gating on the concrete TestableIO + // receiver type ensures we only run when the swap is well-defined. + SemanticModel? semanticModel = await context.Document + .GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null || !IsConcreteMockFileSystemReceiver(memberAccess.Expression, semanticModel)) + { + return; + } + + if (!TryClassifyMockDriveDataInitializer(invocation.ArgumentList.Arguments[1].Expression, out _)) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + Resources.TestablyM001CodeFixTitle, + ct => ApplyAddDriveRewriteAsync(context.Document, diagnostic, ct), + equivalenceKey: Patterns.MockFileSystemAddDrive), + diagnostic); + } + + private static async Task ApplyAddDriveRewriteAsync( + 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); + InvocationExpressionSyntax? invocation = node?.FirstAncestorOrSelf(); + if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess + || invocation.ArgumentList.Arguments.Count != 2) + { + return document; + } + + ArgumentSyntax driveNameArg = invocation.ArgumentList.Arguments[0]; + ExpressionSyntax driveDataExpr = invocation.ArgumentList.Arguments[1].Expression; + if (!TryClassifyMockDriveDataInitializer(driveDataExpr, + out List? assignments)) + { + return document; + } + + InvocationExpressionSyntax replacement = BuildWithDriveInvocation( + memberAccess.Expression, driveNameArg, assignments); + compilationUnit = compilationUnit.ReplaceNode(invocation, replacement.WithTriviaFrom(invocation)); + compilationUnit = SwapToTestablyUsing(compilationUnit); + return document.WithSyntaxRoot(compilationUnit); + } + + private static bool TryClassifyMockDriveDataInitializer( + ExpressionSyntax driveDataExpr, + out List? assignments) + { + assignments = null; + + ArgumentListSyntax? argumentList; + InitializerExpressionSyntax? initializer; + switch (driveDataExpr) + { + case ObjectCreationExpressionSyntax explicitCreation: + argumentList = explicitCreation.ArgumentList; + initializer = explicitCreation.Initializer; + break; + case ImplicitObjectCreationExpressionSyntax implicitCreation: + argumentList = implicitCreation.ArgumentList; + initializer = implicitCreation.Initializer; + break; + default: + return false; + } + + // Reject ctor overloads with arguments (e.g. the MockDriveData copy ctor) — + // they have no 1:1 mapping to WithDrive's lambda surface. + if (argumentList is { Arguments.Count: > 0, }) + { + return false; + } + + assignments = []; + if (initializer is null) + { + return true; + } + + foreach (ExpressionSyntax expression in initializer.Expressions) + { + if (expression is not AssignmentExpressionSyntax assignment + || assignment.Left is not IdentifierNameSyntax property + || MapMockDriveDataProperty(property.Identifier.Text) is null) + { + assignments = null; + return false; + } + + assignments.Add(assignment); + } + + return true; + } + + private static InvocationExpressionSyntax BuildWithDriveInvocation( + ExpressionSyntax receiver, + ArgumentSyntax driveNameArg, + List assignments) + { + MemberAccessExpressionSyntax withDriveAccess = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + receiver, + SyntaxFactory.IdentifierName("WithDrive")); + + // Strip NameColon from the kept drive-name argument: TestableIO uses + // `name`, Testably uses `drive` — positional binding is the safe shape. + ArgumentSyntax nameArg = driveNameArg.WithNameColon(null); + + if (assignments.Count == 0) + { + return SyntaxFactory.InvocationExpression( + withDriveAccess, + SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(nameArg))); + } + + SimpleLambdaExpressionSyntax lambda = BuildWithDriveLambda(assignments); + return SyntaxFactory.InvocationExpression( + withDriveAccess, + SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( + new[] { nameArg, SyntaxFactory.Argument(lambda), }))); + } + + private static SimpleLambdaExpressionSyntax BuildWithDriveLambda( + List assignments) + { + // Avoid shadowing identifiers used in any of the initializer RHS expressions. + string parameterName = PickFreshDriveLambdaParameterName(assignments); + ExpressionSyntax body = SyntaxFactory.IdentifierName(parameterName); + foreach (AssignmentExpressionSyntax assignment in assignments) + { + string propertyName = ((IdentifierNameSyntax)assignment.Left).Identifier.Text; + string setter = MapMockDriveDataProperty(propertyName)!; + body = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + body, + SyntaxFactory.IdentifierName(setter)), + SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(assignment.Right.WithoutTrivia())))); + } + + return SyntaxFactory.SimpleLambdaExpression( + SyntaxFactory.Parameter(SyntaxFactory.Identifier(parameterName)), + body); + } + + private static string PickFreshDriveLambdaParameterName(List assignments) + { + HashSet used = []; + foreach (AssignmentExpressionSyntax assignment in assignments) + { + foreach (IdentifierNameSyntax id in assignment.Right.DescendantNodesAndSelf().OfType()) + { + used.Add(id.Identifier.Text); + } + } + + string[] candidates = ["d", "drive", "driveBuilder",]; + foreach (string candidate in candidates) + { + if (!used.Contains(candidate)) + { + return candidate; + } + } + + for (int i = 1;; i++) + { + string n = $"d{i}"; + if (!used.Contains(n)) + { + return n; + } + } + } + + private static string? MapMockDriveDataProperty(string propertyName) => propertyName switch + { + "TotalSize" => "SetTotalSize", + "IsReady" => "SetIsReady", + "DriveFormat" => "SetDriveFormat", + "DriveType" => "SetDriveType", + // AvailableFreeSpace, TotalFreeSpace and VolumeLabel have no IStorageDrive + // setter equivalent — fall through to manual review. + _ => null, + }; + // ── Shared: using-directive swap ───────────────────────────────────────── private static CompilationUnitSyntax SwapToTestablyUsing(CompilationUnitSyntax compilationUnit) diff --git a/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs b/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs index e2a44f9..611ef98 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs @@ -51,6 +51,12 @@ public static class Patterns /// accessor.FileExists(path). public const string AccessorFileExists = "accessor.FileExists"; + /// + /// fs.AddDrive(name, mockDriveData); rewrites to + /// fs.WithDrive(name, d => d.SetTotalSize(...).SetIsReady(...)). + /// + public const string MockFileSystemAddDrive = "MockFileSystem.AddDrive"; + // ── MockFileData property access ────────────────────────────────────── /// A read access to a MockFileData property. diff --git a/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs b/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs index 598ce4d..055731a 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs @@ -230,9 +230,13 @@ private static void AnalyzePropertyReference(OperationAnalysisContext context, T && invocation.TargetMethod.Parameters[0].Type.SpecialType == SpecialType.System_String && invocation.Arguments.Length == 1; - string pattern = isOneShotGetFile - ? (isWrite ? Patterns.MockFileDataPropertyWrite : Patterns.MockFileDataPropertyRead) - : (isWrite ? Patterns.MockFileDataCapturedReferenceWrite : Patterns.MockFileDataCapturedReferenceRead); + string pattern = (isOneShotGetFile, isWrite) switch + { + (true, true) => Patterns.MockFileDataPropertyWrite, + (true, false) => Patterns.MockFileDataPropertyRead, + (false, true) => Patterns.MockFileDataCapturedReferenceWrite, + (false, false) => Patterns.MockFileDataCapturedReferenceRead, + }; Report(context, propertyRef.Syntax.GetLocation(), pattern); } @@ -278,6 +282,7 @@ private static bool IsMockFileSystemOptions(IParameterSymbol parameter) "RemoveFile" => Patterns.AccessorRemoveFile, "MoveDirectory" => Patterns.AccessorMoveDirectory, "FileExists" => Patterns.AccessorFileExists, + "AddDrive" => Patterns.MockFileSystemAddDrive, _ => null, }; diff --git a/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/AccessorMethodTests.cs b/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/AccessorMethodTests.cs index 32b5ef5..bebe718 100644 --- a/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/AccessorMethodTests.cs +++ b/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/AccessorMethodTests.cs @@ -112,4 +112,39 @@ public async Task RemoveFile_WithVerifyAccess_DeletesFile() await That(fs.FileExists("/a")).IsFalse(); } + + [Fact] + public async Task AddDrive_EmptyData_RegistersDrive() + { + MockFileSystem fs = new(); + fs.AddDrive("D:", new MockDriveData()); + + IDriveInfo? drive = FindDriveByName(fs.DriveInfo.GetDrives(), "D:"); + await That(drive).IsNotNull(); + } + + [Fact] + public async Task AddDrive_WithTotalSize_RegistersDriveWithSize() + { + const long totalSize = 1024L * 1024L; + MockFileSystem fs = new(); + fs.AddDrive("E:", new MockDriveData { TotalSize = totalSize }); + + IDriveInfo? drive = FindDriveByName(fs.DriveInfo.GetDrives(), "E:"); + await That(drive).IsNotNull(); + await That(drive!.TotalSize).IsEqualTo(totalSize); + } + + private static IDriveInfo? FindDriveByName(IDriveInfo[] drives, string prefix) + { + foreach (IDriveInfo drive in drives) + { + if (drive.Name.StartsWith(prefix, StringComparison.Ordinal)) + { + return drive; + } + } + + return null; + } } diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs index 33e902e..959a2cc 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs @@ -124,6 +124,24 @@ await Verifier.VerifyAnalyzerAsync( Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); } + [Fact] + public async Task AddDrive_ShouldBeFlagged() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => {|#0:fs.AddDrive("D:", new MockDriveData())|}; + } + """; + + await Verifier.VerifyAnalyzerAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); + } + [Theory] [InlineData("TextContents")] [InlineData("Contents")] diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs index be90798..98614db 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs @@ -386,5 +386,254 @@ await Verifier.VerifyCodeFixAsync( Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), source); } + + [Fact] + public async Task AddDrive_EmptyInitializer_ShouldRewriteToWithDrive() + { + // `new MockDriveData()` with no initializer has nothing to chain — the + // rewrite collapses to the single-argument WithDrive overload. The using + // must also swap, since WithDrive is Testably-only. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) => {|#0:fs.AddDrive("D:", new MockDriveData())|}; + } + """; + + const string fixedSource = """ + using Testably.Abstractions.Testing; + + public class C + { + public void Run(MockFileSystem fs) => fs.WithDrive("D:"); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AddDrive_TotalSizeOnly_ShouldRewriteToWithDriveLambda() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => {|#0:fs.AddDrive("D:", new MockDriveData { TotalSize = 100 })|}; + } + """; + + const string fixedSource = """ + using Testably.Abstractions.Testing; + + public class C + { + public void Run(MockFileSystem fs) + => fs.WithDrive("D:", d => d.SetTotalSize(100)); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AddDrive_AllMappableProperties_ShouldChainSetters() + { + // Initializer order is preserved in the chained call so the user can verify + // any sequence-dependent behaviour by reading the rewrite top-down. + const string source = """ + using System.IO; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + { + {|#0:fs.AddDrive("D:", new MockDriveData + { + TotalSize = 100, + IsReady = false, + DriveFormat = "NTFS", + DriveType = DriveType.Fixed, + })|}; + } + } + """; + + const string fixedSource = """ + using System.IO; + using Testably.Abstractions.Testing; + + public class C + { + public void Run(MockFileSystem fs) + { + fs.WithDrive("D:", d => d.SetTotalSize(100).SetIsReady(false).SetDriveFormat("NTFS").SetDriveType(DriveType.Fixed)); + } + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AddDrive_TargetTypedNew_ShouldRewriteToWithDrive() + { + // `new() { ... }` resolves to MockDriveData via the AddDrive parameter type; + // the rewrite path treats it the same as an explicit `new MockDriveData()`. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => {|#0:fs.AddDrive("D:", new() { TotalSize = 200 })|}; + } + """; + + const string fixedSource = """ + using Testably.Abstractions.Testing; + + public class C + { + public void Run(MockFileSystem fs) + => fs.WithDrive("D:", d => d.SetTotalSize(200)); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AddDrive_ShadowingDriveIdentifier_PicksFreshLambdaParameter() + { + // The initializer RHS references an outer `d`; the rewrite must not let the + // lambda parameter shadow it. PickFreshDriveLambdaParameterName falls through + // to `drive` (next candidate). + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs, long d) + => {|#0:fs.AddDrive("D:", new MockDriveData { TotalSize = d })|}; + } + """; + + const string fixedSource = """ + using Testably.Abstractions.Testing; + + public class C + { + public void Run(MockFileSystem fs, long d) + => fs.WithDrive("D:", drive => drive.SetTotalSize(d)); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AddDrive_InterfaceTypedReceiver_HasNoFix() + { + // The rewrite emits `.WithDrive(...)`. IMockFileDataAccessor has no + // WithDrive, so the fix must not run when the user calls through the + // interface. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(IMockFileDataAccessor accessor) + => {|#0:accessor.AddDrive("D:", new MockDriveData())|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } + + [Fact] + public async Task AddDrive_NonLiteralMockDriveData_HasNoFix() + { + // A captured MockDriveData reference (parameter, local, field, etc.) has no + // safe textual rewrite — the user may pass a subclass or mutate the data. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs, MockDriveData data) + => {|#0:fs.AddDrive("D:", data)|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } + + [Fact] + public async Task AddDrive_UnsupportedInitializerProperty_HasNoFix() + { + // AvailableFreeSpace has no IStorageDrive setter — manual review required. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => {|#0:fs.AddDrive("D:", new MockDriveData { AvailableFreeSpace = 50 })|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } + + [Fact] + public async Task AddDrive_CopyConstructor_HasNoFix() + { + // `new MockDriveData(template)` has no 1:1 mapping to a single WithDrive + // callback — the user might tweak fields after the copy. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs, MockDriveData template) + => {|#0:fs.AddDrive("D:", new MockDriveData(template))|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } } } From 36442e623b7ebf574801ca7da69b009db683efbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 15 May 2026 09:16:12 +0200 Subject: [PATCH 2/2] fix: gate AddDrive fix on syntactically retargetable receiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 5.4 gate only checked the receiver's semantic type, so the fix was still offered when the receiver's declared type syntax was alias- or fully-qualified — e.g. `System.IO.Abstractions.TestingHelpers.MockFileSystem fs` or `using TestableIo = ...; TestableIo.MockFileSystem fs`. In those cases the using-swap leaves the declaration untouched, so after the rewrite `fs` still binds to TestableIO MockFileSystem and `fs.WithDrive(...)` fails to compile. Add a syntactic gate IsRetargetableMockFileSystemReceiver that inspects the declaration of the receiver symbol (local, parameter, field, property or method return type) and only accepts unqualified type syntax (IdentifierNameSyntax, optionally wrapped in NullableTypeSyntax). Direct constructions reuse the existing HasUnqualifiedMockFileSystemTypeName gate. Anything else (qualified, alias-qualified, missing source declaration) falls through to manual review. Tests added for the fully-qualified and alias-qualified receiver shapes. --- .../SystemIOAbstractionsCodeFixProvider.cs | 62 ++++++++++++++++++- ...odeFixProviderTests.AccessorMethodTests.cs | 43 +++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs index 991e234..1b474a5 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs @@ -1246,11 +1246,16 @@ private static async Task TryRegisterAddDriveFixAsync( } // The rewrite emits `.WithDrive(...)`. WithDrive is Testably-only, so - // we must swap the using as part of the fix. Gating on the concrete TestableIO - // receiver type ensures we only run when the swap is well-defined. + // we must swap the using as part of the fix. The semantic check confirms the + // receiver is currently typed as TestableIO MockFileSystem; the syntactic check + // below confirms the declaration's type syntax can actually be retargeted by + // the using swap (alias- or fully-qualified declarations stay bound to + // TestableIO after the swap, so the rewrite would produce non-compiling code). SemanticModel? semanticModel = await context.Document .GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); - if (semanticModel is null || !IsConcreteMockFileSystemReceiver(memberAccess.Expression, semanticModel)) + if (semanticModel is null + || !IsConcreteMockFileSystemReceiver(memberAccess.Expression, semanticModel) + || !IsRetargetableMockFileSystemReceiver(memberAccess.Expression, semanticModel)) { return; } @@ -1268,6 +1273,57 @@ private static async Task TryRegisterAddDriveFixAsync( diagnostic); } + private static bool IsRetargetableMockFileSystemReceiver( + ExpressionSyntax receiver, SemanticModel semanticModel) + { + // Direct construction: the construction expression itself is what the swap + // retargets, so re-use the constructor-level gate. + if (receiver is BaseObjectCreationExpressionSyntax creation) + { + return HasUnqualifiedMockFileSystemTypeName(creation); + } + + // Symbol references (locals, parameters, fields, properties, method results): + // inspect the declared type syntax. The swap only retargets unqualified + // `MockFileSystem` (or `var` resolved from an unqualified initializer). Alias- + // qualified (`TestableIo.MockFileSystem`) and fully-qualified + // (`System.IO.Abstractions.TestingHelpers.MockFileSystem`) declarations stay + // bound to TestableIO after the swap, so the rewrite would emit `WithDrive` on + // the old MockFileSystem and fail to compile. + ISymbol? symbol = semanticModel.GetSymbolInfo(receiver).Symbol; + if (symbol is null || symbol.DeclaringSyntaxReferences.Length == 0) + { + return false; + } + + foreach (SyntaxReference declRef in symbol.DeclaringSyntaxReferences) + { + TypeSyntax? declaredType = declRef.GetSyntax() switch + { + VariableDeclaratorSyntax v => (v.Parent as VariableDeclarationSyntax)?.Type, + ParameterSyntax p => p.Type, + PropertyDeclarationSyntax pd => pd.Type, + MethodDeclarationSyntax md => md.ReturnType, + _ => null, + }; + + if (declaredType is null || !IsUnqualifiedMockFileSystemTypeSyntax(declaredType)) + { + return false; + } + } + + return true; + } + + private static bool IsUnqualifiedMockFileSystemTypeSyntax(TypeSyntax typeSyntax) + => typeSyntax switch + { + IdentifierNameSyntax => true, + NullableTypeSyntax nullable => IsUnqualifiedMockFileSystemTypeSyntax(nullable.ElementType), + _ => false, + }; + private static async Task ApplyAddDriveRewriteAsync( Document document, Diagnostic diagnostic, CancellationToken cancellationToken) { diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs index 98614db..5962e04 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs @@ -635,5 +635,48 @@ await Verifier.VerifyCodeFixAsync( Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), source); } + + [Fact] + public async Task AddDrive_FullyQualifiedReceiverDeclaration_HasNoFix() + { + // The parameter declaration uses the fully-qualified type name, so the + // using-swap can't retarget the receiver — after the swap, `fs` still binds + // to TestableIO MockFileSystem and `fs.WithDrive(...)` would not compile. + const string source = """ + public class C + { + public void Run(System.IO.Abstractions.TestingHelpers.MockFileSystem fs) + => {|#0:fs.AddDrive("D:", new System.IO.Abstractions.TestingHelpers.MockDriveData())|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } + + [Fact] + public async Task AddDrive_AliasQualifiedReceiverDeclaration_HasNoFix() + { + // The parameter declaration is alias-qualified (`TestableIo.MockFileSystem`). + // The using-swap touches `using System.IO.Abstractions.TestingHelpers;` but + // leaves the alias `using TestableIo = ...;` in place, so `fs` stays bound to + // TestableIO and the rewrite would not compile. + const string source = """ + using TestableIo = System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(TestableIo.MockFileSystem fs) + => {|#0:fs.AddDrive("D:", new TestableIo.MockDriveData())|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } } }