Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand Down Expand Up @@ -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<InvocationExpressionSyntax>();
if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess
|| invocation.ArgumentList.Arguments.Count != 2)
{
return;
}

// The rewrite emits `<receiver>.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))
Comment thread
vbreuss marked this conversation as resolved.
Outdated
{
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<Document> 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<InvocationExpressionSyntax>();
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<AssignmentExpressionSyntax>? 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<AssignmentExpressionSyntax>? 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<AssignmentExpressionSyntax> 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<AssignmentExpressionSyntax> 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<AssignmentExpressionSyntax> assignments)
{
HashSet<string> used = [];
foreach (AssignmentExpressionSyntax assignment in assignments)
{
foreach (IdentifierNameSyntax id in assignment.Right.DescendantNodesAndSelf().OfType<IdentifierNameSyntax>())
{
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)
Expand Down
6 changes: 6 additions & 0 deletions Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ public static class Patterns
/// <summary><c>accessor.FileExists(path)</c>.</summary>
public const string AccessorFileExists = "accessor.FileExists";

/// <summary>
/// <c>fs.AddDrive(name, mockDriveData)</c>; rewrites to
/// <c>fs.WithDrive(name, d =&gt; d.SetTotalSize(...).SetIsReady(...))</c>.
/// </summary>
public const string MockFileSystemAddDrive = "MockFileSystem.AddDrive";

// ── MockFileData property access ──────────────────────────────────────

/// <summary>A read access to a <c>MockFileData</c> property.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -278,6 +282,7 @@ private static bool IsMockFileSystemOptions(IParameterSymbol parameter)
"RemoveFile" => Patterns.AccessorRemoveFile,
"MoveDirectory" => Patterns.AccessorMoveDirectory,
"FileExists" => Patterns.AccessorFileExists,
"AddDrive" => Patterns.MockFileSystemAddDrive,
_ => null,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,39 @@

await That(fs.FileExists("/a")).IsFalse();
}

[Fact]
public async Task AddDrive_EmptyData_RegistersDrive()
{
MockFileSystem fs = new();
fs.AddDrive("D:", new MockDriveData());

Check warning on line 120 in Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/AccessorMethodTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

System.IO.Abstractions MockFileSystem should be migrated to Testably.Abstractions.

See more on https://sonarcloud.io/project/issues?id=Testably_Testably.Abstractions.Migration&issues=AZ4qcIY-HE4tGNmZG2MT&open=AZ4qcIY-HE4tGNmZG2MT&pullRequest=13

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

Check warning on line 131 in Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/AccessorMethodTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

System.IO.Abstractions MockFileSystem should be migrated to Testably.Abstractions.

See more on https://sonarcloud.io/project/issues?id=Testably_Testably.Abstractions.Migration&issues=AZ4qcIY-HE4tGNmZG2MV&open=AZ4qcIY-HE4tGNmZG2MV&pullRequest=13

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading
Loading