Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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,273 @@ 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. 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)
|| !IsRetargetableMockFileSystemReceiver(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 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<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 @@ 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;
}
}
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