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 @@ -93,6 +93,10 @@ await TryRegisterFilesCtorFixAsync(context, diagnostic, node, pattern)
await TryRegisterAddDriveFixAsync(context, diagnostic, node)
.ConfigureAwait(false);
break;
case Patterns.MockFileSystemAddFilesFromEmbeddedNamespace:
await TryRegisterAddFilesFromEmbeddedNamespaceFixAsync(context, diagnostic, node)
.ConfigureAwait(false);
break;
}
}
}
Expand Down Expand Up @@ -1500,8 +1504,225 @@ private static string PickFreshDriveLambdaParameterName(List<AssignmentExpressio
_ => null,
};

// ── Pattern: MockFileSystem.AddFilesFromEmbeddedNamespace ────────────────

private static async Task TryRegisterAddFilesFromEmbeddedNamespaceFixAsync(
CodeFixContext context, Diagnostic diagnostic, SyntaxNode node)
{
InvocationExpressionSyntax? invocation = node.FirstAncestorOrSelf<InvocationExpressionSyntax>();
if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess
|| invocation.ArgumentList.Arguments.Count != 3)
{
return;
}

// The Testably target (`fileSystem.InitializeEmbeddedResourcesFromAssembly(...)`)
// is an extension method on `IFileSystem`, so any `IFileSystem`-implementing
// receiver would in principle bind. We deliberately tighten that to the concrete
// TestableIO `MockFileSystem` via `IsConcreteMockFileSystemReceiver`: it is the
// receiver shape this migration is designed to flag, and it keeps the gate
// consistent with sibling accessor fixes (AddFile, AddDirectory, etc.). The
// `IMockFileDataAccessor` interface in particular does NOT extend `IFileSystem`,
// so the rewritten call would not bind through that path.
SemanticModel? semanticModel = await context.Document
.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
if (semanticModel is null
|| !IsConcreteMockFileSystemReceiver(memberAccess.Expression, semanticModel))
{
return;
}

if (!TryComputeRelativePathFromAssemblyAndLiteral(
invocation.ArgumentList.Arguments[1],
invocation.ArgumentList.Arguments[2],
semanticModel,
context.CancellationToken,
out _))
{
return;
}

context.RegisterCodeFix(
CodeAction.Create(
Resources.TestablyM001CodeFixTitle,
ct => ApplyAddFilesFromEmbeddedNamespaceRewriteAsync(context.Document, diagnostic, ct),
equivalenceKey: Patterns.MockFileSystemAddFilesFromEmbeddedNamespace),
diagnostic);
}

private static async Task<Document> ApplyAddFilesFromEmbeddedNamespaceRewriteAsync(
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<InvocationExpressionSyntax>();
if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess
|| invocation.ArgumentList.Arguments.Count != 3)
{
return document;
}

ArgumentSyntax pathArg = invocation.ArgumentList.Arguments[0];
ArgumentSyntax assemblyArg = invocation.ArgumentList.Arguments[1];
if (!TryComputeRelativePathFromAssemblyAndLiteral(
assemblyArg,
invocation.ArgumentList.Arguments[2],
semanticModel,
cancellationToken,
out string? relativePath))
{
return document;
}

MemberAccessExpressionSyntax newAccess = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
memberAccess.Expression,
SyntaxFactory.IdentifierName("InitializeEmbeddedResourcesFromAssembly"));

// Strip NameColon on positional arguments. TestableIO uses `path` / `resourceAssembly`
// while Testably uses `directoryPath` / `assembly` — keeping the labels would not bind.
ArgumentSyntax newPath = pathArg.WithNameColon(null).WithoutTrivia();
ArgumentSyntax newAssembly = assemblyArg.WithNameColon(null).WithoutTrivia();

SeparatedSyntaxList<ArgumentSyntax> args = SyntaxFactory.SeparatedList(
new[] { newPath, newAssembly, });
if (relativePath is not null)
{
args = args.Add(
SyntaxFactory.Argument(
SyntaxFactory.NameColon(SyntaxFactory.IdentifierName("relativePath")),
refKindKeyword: default,
expression: SyntaxFactory.LiteralExpression(
SyntaxKind.StringLiteralExpression,
SyntaxFactory.Literal(relativePath))));
}

InvocationExpressionSyntax replacement = SyntaxFactory.InvocationExpression(
newAccess, SyntaxFactory.ArgumentList(args));

compilationUnit = compilationUnit.ReplaceNode(invocation, replacement.WithTriviaFrom(invocation));
compilationUnit = EnsureTestablyUsing(compilationUnit);
return document.WithSyntaxRoot(compilationUnit);
}

private static bool TryComputeRelativePathFromAssemblyAndLiteral(
ArgumentSyntax assemblyArg,
ArgumentSyntax embeddedResourcePathArg,
SemanticModel semanticModel,
CancellationToken cancellationToken,
out string? relativePath)
{
relativePath = null;

if (embeddedResourcePathArg.Expression is not LiteralExpressionSyntax literal
|| !literal.IsKind(SyntaxKind.StringLiteralExpression))
{
return false;
}

string? assemblyName = TryResolveAssemblyName(assemblyArg.Expression, semanticModel, cancellationToken);
if (assemblyName is null)
{
return false;
}

string literalValue = literal.Token.ValueText;
string prefix = assemblyName + ".";

// Empty remainder = "literal is exactly the assembly name (with or without trailing
// dot)". Both correspond to "no relativePath filter" in Testably; emit no
// relativePath argument so the call materializes every embedded resource (matching
// TestableIO's `StartsWith(<asm-name>)` behavior).
if (literalValue == assemblyName || literalValue == prefix)
{
relativePath = null;
return true;
}

if (!literalValue.StartsWith(prefix, System.StringComparison.Ordinal))
{
return false;
}

string remainder = literalValue.Substring(prefix.Length);
if (remainder.Length == 0)
{
relativePath = null;
return true;
}

// Forward slash works cross-platform: Testably normalizes
// AltDirectorySeparatorChar to DirectorySeparatorChar before matching.
relativePath = remainder.Replace('.', '/');
return true;
}

private static string? TryResolveAssemblyName(
ExpressionSyntax expression, SemanticModel semanticModel, CancellationToken cancellationToken)
{
// Shape 1: `typeof(SomeType).Assembly`. Resolve the type via the semantic model
// and read the containing assembly's name.
if (expression is MemberAccessExpressionSyntax
{
Name.Identifier.Text: "Assembly",
Expression: TypeOfExpressionSyntax typeOf,
})
{
TypeInfo info = semanticModel.GetTypeInfo(typeOf.Type, cancellationToken);
ITypeSymbol? typeSymbol = info.Type;
IAssemblySymbol? assembly = typeSymbol?.ContainingAssembly;
return assembly?.Name;
}

// Shape 2: `Assembly.GetExecutingAssembly()` (or any qualified form thereof). The
// executing assembly is the assembly currently being compiled — which is the
// SemanticModel's compilation assembly. Resolve via symbol lookup so we handle
// both `Assembly.GetExecutingAssembly()` and `System.Reflection.Assembly.
// GetExecutingAssembly()` uniformly. (`GetCallingAssembly` cannot be resolved
// statically — its return value depends on the caller frame at runtime.)
if (expression is InvocationExpressionSyntax invocation
&& semanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol
is IMethodSymbol
{
Name: "GetExecutingAssembly",
Parameters.Length: 0,
ContainingType:
{
Name: "Assembly",
ContainingNamespace: { Name: "Reflection", ContainingNamespace.Name: "System", },
},
})
{
return semanticModel.Compilation.AssemblyName;
}

return null;
}

// ── Shared: using-directive swap ─────────────────────────────────────────

private static CompilationUnitSyntax EnsureTestablyUsing(CompilationUnitSyntax compilationUnit)
{
if (compilationUnit.Usings.Any(u => u.Name?.ToString() == TestablyTestingNamespace))
{
return compilationUnit;
}

UsingDirectiveSyntax usingDirective = BuildUsingDirective(compilationUnit, TestablyTestingNamespace);
return compilationUnit.AddUsings(usingDirective);
}

private static CompilationUnitSyntax SwapToTestablyUsing(CompilationUnitSyntax compilationUnit)
{
UsingDirectiveSyntax? testingHelpersUsing = compilationUnit.Usings
Expand Down
24 changes: 24 additions & 0 deletions Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,30 @@ public static class Patterns
/// </summary>
public const string MockFileSystemMockTime = "MockFileSystem.MockTime";

/// <summary>
/// <c>fs.AddFileFromEmbeddedResource(path, assembly, embeddedResourcePath)</c>.
/// Manual review (Phase 5.2): Testably exposes only a bulk
/// <c>InitializeEmbeddedResourcesFromAssembly(directoryPath, assembly,
/// relativePath, searchPattern, searchOption)</c> with no single-file overload,
/// and uses a path-style match (auto-strips the assembly-name prefix and
/// replaces dots with directory separators) instead of TestableIO's literal
/// dot-prefix match. A naive textual rewrite would compile but materialize a
/// different resource set, so the call site is reported for manual migration.
/// </summary>
public const string MockFileSystemAddFileFromEmbeddedResource =
"MockFileSystem.AddFileFromEmbeddedResource";

/// <summary>
/// <c>fs.AddFilesFromEmbeddedNamespace(path, assembly, embeddedResourcePath)</c>.
Comment thread
vbreuss marked this conversation as resolved.
/// Phase 5.2: when the assembly arg resolves statically (<c>typeof(X).Assembly</c>
/// or <c>Assembly.GetExecutingAssembly()</c>) and the third arg is a string
/// literal that starts with the resolved assembly name, the code-fix rewrites to
/// Testably's <c>InitializeEmbeddedResourcesFromAssembly</c>. Otherwise the call
/// site is left for manual review.
/// </summary>
public const string MockFileSystemAddFilesFromEmbeddedNamespace =
"MockFileSystem.AddFilesFromEmbeddedNamespace";

// ── Enumeration properties (Phase 5.1) ────────────────────────────────
// These IMockFileDataAccessor properties enumerate the whole mocked file
// system. Testably has no direct equivalent — the natural replacements
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,8 @@ private static bool IsMockFileSystemOptions(IParameterSymbol parameter)
"FileExists" => Patterns.AccessorFileExists,
"AddDrive" => Patterns.MockFileSystemAddDrive,
"MockTime" => Patterns.MockFileSystemMockTime,
"AddFileFromEmbeddedResource" => Patterns.MockFileSystemAddFileFromEmbeddedResource,
"AddFilesFromEmbeddedNamespace" => Patterns.MockFileSystemAddFilesFromEmbeddedNamespace,
_ => null,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,39 @@ public async Task MockFileSystem_MockTime_ReturnsSelfForFluentChaining()
await That(chained).IsSameAs(fs);
}

[Fact]
public async Task MockFileSystem_AddFileFromEmbeddedResource_MaterializesEmbeddedFile()
{
// TestableIO matches the resource name literally; Testably exposes only a
// bulk InitializeEmbeddedResourcesFromAssembly with no single-file overload.
// Manual review: pattern id `MockFileSystem.AddFileFromEmbeddedResource`.
MockFileSystem fs = new();
fs.AddFileFromEmbeddedResource(
"/data/sample.txt",
typeof(ManualReviewTests).Assembly,
"Testably.Abstractions.Migration.SystemIOAbstractionsPlayground.TestData.sample.txt");

await That(fs.File.ReadAllText("/data/sample.txt").Trim())
.IsEqualTo("embedded-resource-content");
}

[Fact]
public async Task MockFileSystem_AddFilesFromEmbeddedNamespace_MaterializesMatchingFiles()
{
// TestableIO uses a literal StartsWith on the assembly-qualified resource name,
// dropping the matched prefix + one separator dot to derive each filename.
// The Phase 5.2 code-fix rewrites this to Testably's
// InitializeEmbeddedResourcesFromAssembly when the assembly resolves statically.
MockFileSystem fs = new();
fs.AddFilesFromEmbeddedNamespace(
"/data",
typeof(ManualReviewTests).Assembly,
"Testably.Abstractions.Migration.SystemIOAbstractionsPlayground.TestData");

await That(fs.File.ReadAllText("/data/sample.txt").Trim())
.IsEqualTo("embedded-resource-content");
}

private sealed class MyMockFs : MockFileSystem
{
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
embedded-resource-content
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,8 @@
<PackageReference Include="System.IO.Abstractions"/>
<PackageReference Include="System.IO.Abstractions.TestingHelpers"/>
</ItemGroup>

<ItemGroup>
<EmbeddedResource Include="TestData\sample.txt"/>
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,44 @@ await Verifier.VerifyAnalyzerAsync(
Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0));
}

[Fact]
public async Task AddFileFromEmbeddedResource_ShouldBeFlagged()
{
const string source = """
using System.IO.Abstractions.TestingHelpers;
using System.Reflection;

public class C
{
public void Run(MockFileSystem fs, Assembly asm)
=> {|#0:fs.AddFileFromEmbeddedResource("/data/foo.json", asm, "MyAssembly.TestData.foo.json")|};
}
""";

await Verifier.VerifyAnalyzerAsync(
source,
Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0));
}

[Fact]
public async Task AddFilesFromEmbeddedNamespace_ShouldBeFlagged()
{
const string source = """
using System.IO.Abstractions.TestingHelpers;
using System.Reflection;

public class C
{
public void Run(MockFileSystem fs, Assembly asm)
=> {|#0:fs.AddFilesFromEmbeddedNamespace("/data", asm, "MyAssembly.TestData")|};
}
""";

await Verifier.VerifyAnalyzerAsync(
source,
Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0));
}

[Theory]
[InlineData("AllPaths")]
[InlineData("AllFiles")]
Expand Down
Loading
Loading