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
15 changes: 14 additions & 1 deletion Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,14 @@ public static class Patterns
/// <summary>A write/assignment to a <c>MockFileData</c> property.</summary>
public const string MockFileDataPropertyWrite = "MockFileData.propertyWrite";

// ── Manual-review patterns (Phase 4a) ─────────────────────────────────
// ── Manual-review patterns (Phase 4) ──────────────────────────────────
// These call sites have no automatic rewrite because Testably.Abstractions
// has no equivalent surface for the captured concept. The analyzer flags
// them with a discriminating pattern id so the user can locate and address
// each manually; the code-fix provider intentionally registers no fix.

// Phase 4a: lossy MockFileData properties + MockFileVersionInfo.

/// <summary><c>MockFileData.AccessControl</c> — Windows-only FileSecurity has no Testably equivalent.</summary>
public const string MockFileDataAccessControl = "MockFileData.AccessControl";

Expand All @@ -76,4 +78,15 @@ public static class Patterns

/// <summary><c>new MockFileVersionInfo(...)</c> — file version metadata has no Testably equivalent.</summary>
public const string MockFileVersionInfoConstructor = "MockFileVersionInfo.ctor";

// Phase 4b: subclasses + MockFileData copy constructor.

/// <summary>A user-defined class derives from <c>MockFileSystem</c>; the inheritance contract differs in Testably.</summary>
public const string MockFileSystemSubclass = "MockFileSystem.subclass";

/// <summary>A user-defined class derives from <c>MockFileData</c>; there is no Testably equivalent.</summary>
public const string MockFileDataSubclass = "MockFileData.subclass";

/// <summary><c>new MockFileData(MockFileData template)</c> — clone semantics differ; no Testably equivalent.</summary>
public const string MockFileDataCopyConstructor = "MockFileData.copyCtor";
Comment thread
vbreuss marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,61 @@ public override void Initialize(AnalysisContext context)
start.RegisterOperationAction(
ctx => AnalyzePropertyReference(ctx, symbols),
OperationKind.PropertyReference);

start.RegisterSymbolAction(
ctx => AnalyzeNamedTypeDeclaration(ctx, symbols),
SymbolKind.NamedType);
});
}

private static void AnalyzeNamedTypeDeclaration(SymbolAnalysisContext context, TestableIoSymbols symbols)
{
if (context.Symbol is not INamedTypeSymbol named || named.TypeKind != TypeKind.Class)
{
return;
}

// Skip the framework types themselves — only user-defined subclasses need migration.
if (SymbolEqualityComparer.Default.Equals(named, symbols.MockFileSystem)
|| (symbols.MockFileData is { } mockFileData
&& SymbolEqualityComparer.Default.Equals(named, mockFileData)))
{
return;
}

string? pattern = ClassifySubclass(named, symbols);
if (pattern is null)
{
return;
}

// Locations covers every partial declaration; reporting on each surfaces all of
// them to the user. The set is usually a single location.
foreach (Location location in named.Locations)
{
Report(context, location, pattern);
}
}

private static string? ClassifySubclass(INamedTypeSymbol named, TestableIoSymbols symbols)
{
for (INamedTypeSymbol? baseType = named.BaseType; baseType is not null; baseType = baseType.BaseType)
{
if (SymbolEqualityComparer.Default.Equals(baseType, symbols.MockFileSystem))
{
return Patterns.MockFileSystemSubclass;
}

if (symbols.MockFileData is { } mockFileData
&& SymbolEqualityComparer.Default.Equals(baseType, mockFileData))
{
return Patterns.MockFileDataSubclass;
}
}

return null;
}

private static void AnalyzeObjectCreation(OperationAnalysisContext context, TestableIoSymbols symbols)
{
if (context.Operation is not IObjectCreationOperation creation)
Expand Down Expand Up @@ -78,6 +130,19 @@ private static void AnalyzeObjectCreation(OperationAnalysisContext context, Test
&& SymbolEqualityComparer.Default.Equals(constructor.ContainingType, mockFileVersionInfo))
{
Report(context, creation.Syntax.GetLocation(), Patterns.MockFileVersionInfoConstructor);
return;
}

// Phase 4b manual-review: MockFileData copy constructor. Cloning semantics differ
// across libraries and Testably has no equivalent. We only fire for the explicit
// 1-parameter MockFileData overload — the encoding/byte/text ctors are part of
// existing AddFile expansion (Phases 2/3.5) and must keep their current pattern.
if (symbols.MockFileData is { } mockFileData
&& SymbolEqualityComparer.Default.Equals(constructor.ContainingType, mockFileData)
&& constructor.Parameters.Length == 1
&& SymbolEqualityComparer.Default.Equals(constructor.Parameters[0].Type, mockFileData))
{
Report(context, creation.Syntax.GetLocation(), Patterns.MockFileDataCopyConstructor);
}
}

Expand Down Expand Up @@ -204,14 +269,20 @@ private static bool IsMockFileSystemOptions(IParameterSymbol parameter)
};

private static void Report(OperationAnalysisContext context, Location location, string pattern)
=> context.ReportDiagnostic(BuildDiagnostic(location, pattern));

private static void Report(SymbolAnalysisContext context, Location location, string pattern)
=> context.ReportDiagnostic(BuildDiagnostic(location, pattern));

private static Diagnostic BuildDiagnostic(Location location, string pattern)
{
ImmutableDictionary<string, string?> properties =
new Dictionary<string, string?> { [Patterns.Key] = pattern, }.ToImmutableDictionary();

context.ReportDiagnostic(Diagnostic.Create(
return Diagnostic.Create(
Rules.SystemIOAbstractionsRule,
location,
properties,
messageArgs: null));
messageArgs: null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
namespace Testably.Abstractions.Migration.SystemIOAbstractionsPlayground;

/// <summary>
/// Manual-review fixtures (Phase 4a): call sites for <see cref="MockFileData" />
/// properties and <see cref="MockFileVersionInfo" /> construction that have no
/// equivalent in <c>Testably.Abstractions.Testing</c>. The analyzer flags each one
/// with a discriminating pattern id; the code-fix provider intentionally registers
/// no rewrite, so these tests stand as the parity baseline a human can use to plan
/// the migration manually.
/// Manual-review fixtures (Phase 4): call sites that have no equivalent in
/// <c>Testably.Abstractions.Testing</c>. Phase 4a covers lossy
/// <see cref="MockFileData" /> properties (AccessControl, AllowedFileShare,
/// UnixMode) and <see cref="MockFileVersionInfo" /> construction. Phase 4b adds
/// user-defined subclasses of <see cref="MockFileSystem" /> and
/// <see cref="MockFileData" /> plus the <see cref="MockFileData" /> copy
/// constructor. The analyzer flags each one with a discriminating pattern id; the
/// code-fix provider intentionally registers no rewrite, so these tests stand as
/// the parity baseline a human can use to plan the migration manually.
/// </summary>
public class ManualReviewTests
{
Expand Down Expand Up @@ -54,4 +57,39 @@ public async Task MockFileVersionInfo_Constructor_ExposesMetadata()
await That(info.FileVersion).IsEqualTo("1.2.3");
await That(info.ProductName).IsEqualTo("Sample");
}

[Fact]
public async Task MockFileSystemSubclass_BehavesAsMockFileSystem()
{
MyMockFs fs = new();
fs.AddFile("/a", new MockFileData("hello"));

await That(fs.File.ReadAllText("/a")).IsEqualTo("hello");
}

[Fact]
public async Task MockFileDataSubclass_BehavesAsMockFileData()
{
MyMockFileData data = new();

await That(data.TextContents).IsEqualTo("hello");
}

[Fact]
public async Task MockFileData_CopyConstructor_ClonesTextContents()
{
Comment thread
vbreuss marked this conversation as resolved.
MockFileData template = new("hello");
MockFileData clone = new(template);

await That(clone.TextContents).IsEqualTo("hello");
}

private sealed class MyMockFs : MockFileSystem
{
}

private sealed class MyMockFileData : MockFileData
{
public MyMockFileData() : base("hello") { }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,77 @@ await Verifier.VerifyAnalyzerAsync(
Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0));
}

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

public class {|#0:MyMockFs|} : MockFileSystem
{
}
""";

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

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

public class {|#0:Intermediate|} : MockFileSystem
{
}

public class {|#1:Leaf|} : Intermediate
{
}
""";

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

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

public class {|#0:MyData|} : MockFileData
{
public MyData() : base("hello") { }
}
""";

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

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

public class C
{
public MockFileData Clone(MockFileData template) => {|#0:new MockFileData(template)|};
}
""";

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

[Fact]
public async Task MockFileDataPropertyWrite_ShouldBeFlagged()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,5 +427,26 @@ await Verifier.VerifyCodeFixAsync(
Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0),
fixedSource);
}

[Fact]
public async Task MockFileSystemSubclass_HasNoFix()
{
// User-defined MockFileSystem subclasses don't have a Testably equivalent —
// inheritance hooks differ across libraries. The analyzer flags the class
// declaration; the code-fix provider intentionally falls through with no
// rewrite.
const string source = """
using System.IO.Abstractions.TestingHelpers;

public class {|#0:MyFs|} : MockFileSystem
{
}
""";

await Verifier.VerifyCodeFixAsync(
source,
Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0),
source);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -359,5 +359,47 @@ await Verifier.VerifyCodeFixAsync(
Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0),
source);
}

[Fact]
public async Task MockFileDataSubclass_HasNoFix()
{
// A user-defined MockFileData subclass has no Testably equivalent. The analyzer
// flags the class declaration so the user can locate every fixture; the code-fix
// provider intentionally falls through with no rewrite.
const string source = """
using System.IO.Abstractions.TestingHelpers;

public class {|#0:MyData|} : MockFileData
{
public MyData() : base("hello") { }
}
""";

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

[Fact]
public async Task MockFileDataCopyConstructor_HasNoFix()
{
// MockFileData copy ctor (`new MockFileData(template)`) clones template state.
// Testably has no equivalent clone semantics, so the analyzer flags the call
// site and the code-fix provider intentionally falls through with no rewrite.
const string source = """
using System.IO.Abstractions.TestingHelpers;

public class C
{
public MockFileData Clone(MockFileData template) => {|#0:new MockFileData(template)|};
}
""";

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