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
@@ -1,19 +1,27 @@
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Testably.Abstractions.Migration.Analyzers;

/// <summary>
/// Code fix provider that rewrites <c>System.IO.Abstractions.TestingHelpers</c> usages
/// to <c>Testably.Abstractions.Testing</c> equivalents.
/// </summary>
[ExportCodeFixProvider(Microsoft.CodeAnalysis.LanguageNames.CSharp,
Name = nameof(SystemIOAbstractionsCodeFixProvider))]
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(SystemIOAbstractionsCodeFixProvider))]
[Shared]
public class SystemIOAbstractionsCodeFixProvider : CodeFixProvider
{
private const string TestablyTestingNamespace = "Testably.Abstractions.Testing";
private const string TestingHelpersNamespace = "System.IO.Abstractions.TestingHelpers";

/// <inheritdoc cref="CodeFixProvider.FixableDiagnosticIds" />
public override ImmutableArray<string> FixableDiagnosticIds
=> ImmutableArray.Create(Rules.SystemIOAbstractionsRule.Id);
Expand All @@ -25,10 +33,85 @@ public override ImmutableArray<string> FixableDiagnosticIds
/// <inheritdoc cref="CodeFixProvider.RegisterCodeFixesAsync(CodeFixContext)" />
public override Task RegisterCodeFixesAsync(CodeFixContext context)
{
// TODO: register a CodeAction that rewrites the offending node
// (typically a `new MockFileSystem(...)` invocation, a `new MockFileData(...)`
// initializer, or a member access on `IMockFileDataAccessor`) into the
// equivalent Testably.Abstractions.Testing call.
foreach (Diagnostic diagnostic in context.Diagnostics)
{
if (!diagnostic.Properties.TryGetValue(Patterns.Key, out string? pattern) || pattern is null)
{
continue;
}

switch (pattern)
{
case Patterns.MockFileSystemDefaultConstructor:
context.RegisterCodeFix(
CodeAction.Create(
Resources.TestablyAbstractionsMigration001CodeFixTitle,
ct => RewriteUsingsAsync(context.Document, ct),
equivalenceKey: Patterns.MockFileSystemDefaultConstructor),
diagnostic);
break;
}
}

return Task.CompletedTask;
}

private static async Task<Document> RewriteUsingsAsync(Document document, CancellationToken cancellationToken)
{
SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
if (root is not CompilationUnitSyntax compilationUnit)
{
return document;
}

UsingDirectiveSyntax? testingHelpersUsing = compilationUnit.Usings
.FirstOrDefault(u => u.Name?.ToString() == TestingHelpersNamespace);
bool hasTestablyUsing = compilationUnit.Usings
.Any(u => u.Name?.ToString() == TestablyTestingNamespace);

if (testingHelpersUsing is not null)
{
// Replace in place so the rewrite inherits the original trivia (line endings,
// indentation, leading comments) rather than depending on a fresh syntax token.
if (hasTestablyUsing)
{
compilationUnit =
compilationUnit.RemoveNode(testingHelpersUsing, SyntaxRemoveOptions.KeepNoTrivia)
?? compilationUnit;
}
else
{
NameSyntax newName = SyntaxFactory.ParseName(TestablyTestingNamespace);
UsingDirectiveSyntax replacement = testingHelpersUsing.WithName(newName);
compilationUnit = compilationUnit.ReplaceNode(testingHelpersUsing, replacement);
}
}
else if (!hasTestablyUsing)
{
compilationUnit = AppendUsing(compilationUnit, TestablyTestingNamespace);
}

return document.WithSyntaxRoot(compilationUnit);
}

private static CompilationUnitSyntax AppendUsing(CompilationUnitSyntax compilationUnit, string namespaceName)
{
UsingDirectiveSyntax usingDirective = BuildUsingDirective(compilationUnit, namespaceName);
return compilationUnit.AddUsings(usingDirective);
}

private static UsingDirectiveSyntax BuildUsingDirective(CompilationUnitSyntax compilationUnit, string namespaceName)
{
NameSyntax name = SyntaxFactory.ParseName(namespaceName);

UsingDirectiveSyntax? template = compilationUnit.Usings.LastOrDefault();
if (template is not null)
{
SyntaxToken semicolon = SyntaxFactory.Token(SyntaxKind.SemicolonToken)
.WithTriviaFrom(template.SemicolonToken);
return SyntaxFactory.UsingDirective(name).WithSemicolonToken(semicolon);
}

return SyntaxFactory.UsingDirective(name);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using Microsoft.CodeAnalysis;

namespace Testably.Abstractions.Migration.Analyzers.Common;

/// <summary>
/// Caches the well-known <c>System.IO.Abstractions.TestingHelpers</c> type symbols for a
/// <see cref="Compilation" />. Returns <see langword="null" /> when the
/// <c>System.IO.Abstractions.TestingHelpers</c> assembly is not referenced so the analyzer
/// can bail out cheaply.
/// </summary>
internal sealed class TestableIoSymbols
{
public const string TestingHelpersNamespace = "System.IO.Abstractions.TestingHelpers";

private TestableIoSymbols(
INamedTypeSymbol mockFileSystem,
INamedTypeSymbol? mockFileData,
INamedTypeSymbol? mockDriveData,
INamedTypeSymbol? mockFileDataAccessor)
{
MockFileSystem = mockFileSystem;
MockFileData = mockFileData;
MockDriveData = mockDriveData;
MockFileDataAccessor = mockFileDataAccessor;
}

public INamedTypeSymbol MockFileSystem { get; }

// Auxiliary types are nullable: a future TestingHelpers rename or removal should
// only disable the patterns that actually consume the missing type, not the whole
// analyzer. Call sites that need one of these symbols must null-check first.
public INamedTypeSymbol? MockFileData { get; }
public INamedTypeSymbol? MockDriveData { get; }
public INamedTypeSymbol? MockFileDataAccessor { get; }

public static TestableIoSymbols? TryGetFrom(Compilation compilation)
{
INamedTypeSymbol? mockFileSystem =
compilation.GetTypeByMetadataName(TestingHelpersNamespace + ".MockFileSystem");
if (mockFileSystem is null)
{
return null;
}

return new TestableIoSymbols(
mockFileSystem,
compilation.GetTypeByMetadataName(TestingHelpersNamespace + ".MockFileData"),
compilation.GetTypeByMetadataName(TestingHelpersNamespace + ".MockDriveData"),
compilation.GetTypeByMetadataName(TestingHelpersNamespace + ".IMockFileDataAccessor"));
}
}
18 changes: 18 additions & 0 deletions Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Testably.Abstractions.Migration.Analyzers;

/// <summary>
/// Discriminator values for the <c>pattern</c> property carried by every
/// <see cref="Rules.SystemIOAbstractionsRule" /> diagnostic. The accompanying code fix
/// dispatches on this value to pick the appropriate rewrite.
/// </summary>
public static class Patterns
{
/// <summary>
/// The key under which the pattern discriminator is stored in
/// <see cref="Microsoft.CodeAnalysis.Diagnostic.Properties" />.
/// </summary>
public const string Key = "pattern";

/// <summary>The parameterless <c>new MockFileSystem()</c> constructor.</summary>
public const string MockFileSystemDefaultConstructor = "MockFileSystem.ctor()";
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
using Testably.Abstractions.Migration.Analyzers.Common;

namespace Testably.Abstractions.Migration.Analyzers;

Expand All @@ -22,8 +25,51 @@ public override void Initialize(AnalysisContext context)
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();

// TODO: register the syntax/symbol/operation actions that detect
// System.IO.Abstractions.TestingHelpers usage and report
// Rules.SystemIOAbstractionsRule.
context.RegisterCompilationStartAction(start =>
{
TestableIoSymbols? symbols = TestableIoSymbols.TryGetFrom(start.Compilation);
if (symbols is null)
{
return;
}

start.RegisterOperationAction(
ctx => AnalyzeObjectCreation(ctx, symbols),
OperationKind.ObjectCreation);
});
}

private static void AnalyzeObjectCreation(OperationAnalysisContext context, TestableIoSymbols symbols)
{
if (context.Operation is not IObjectCreationOperation creation)
{
return;
}

INamedTypeSymbol? type = creation.Constructor?.ContainingType;
if (type is null)
{
return;
}

// Phase 1 only handles the parameterless overload. The other three
// MockFileSystem constructors are addressed in Phase 2.
if (SymbolEqualityComparer.Default.Equals(type, symbols.MockFileSystem)
&& creation.Arguments.Length == 0)
{
Report(context, creation, Patterns.MockFileSystemDefaultConstructor);
}
}

private static void Report(OperationAnalysisContext context, IObjectCreationOperation creation, string pattern)
{
ImmutableDictionary<string, string?> properties =
new Dictionary<string, string?> { [Patterns.Key] = pattern, }.ToImmutableDictionary();

context.ReportDiagnostic(Diagnostic.Create(
Rules.SystemIOAbstractionsRule,
creation.Syntax.GetLocation(),
properties,
messageArgs: null));
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
// This file intentionally instantiates both libraries side-by-side for cross-library
// parity checks. The TestableIO usages are permanent — they cannot be migrated away
// without losing the comparison — so the migration analyzer is suppressed for the file.
#pragma warning disable TestablyAbstractionsMigration001

using System.IO;
using TestableIo = System.IO.Abstractions.TestingHelpers;
using TestablyAbstractions = Testably.Abstractions.Testing;

namespace Testably.Abstractions.Migration.Example.Tests;

/// <summary>
Expand All @@ -7,5 +16,45 @@ namespace Testably.Abstractions.Migration.Example.Tests;
/// </summary>
public class SystemIOAbstractionsMigrationExamples
{
// TODO: Add before/after example tests that exercise the migration end-to-end.
/// <summary>
/// Phase 1 risk-#1 sentinel. A rooted Unix-style path on Windows must round-trip
/// identically against both libraries through the parameterless constructor that the
/// Phase 1 code fix preserves verbatim. Any future divergence (case sensitivity,
/// drive-letter handling, separator normalization) will surface here.
/// </summary>
[Theory]
[InlineData("/etc/hosts", "/etc")]
[InlineData("/var/log/syslog", "/var/log")]
public async Task UnixStylePath_ParameterlessCtor_RoundTripsOnBothLibraries(
string filePath, string parentDirectory)
{
const string contents = "127.0.0.1 localhost";

TestableIo.MockFileSystem testableIo = new();
testableIo.Directory.CreateDirectory(parentDirectory);
testableIo.File.WriteAllText(filePath, contents);
string testableIoResult = testableIo.File.ReadAllText(filePath);

TestablyAbstractions.MockFileSystem testably = new();
testably.Directory.CreateDirectory(parentDirectory);
testably.File.WriteAllText(filePath, contents);
string testablyResult = testably.File.ReadAllText(filePath);

await That(testableIoResult).IsEqualTo(contents);
await That(testablyResult).IsEqualTo(testableIoResult);
}

/// <summary>
/// Mirrors the playground sample <c>MockFileSystemSamples.Parameterless</c>: after the
/// code fix runs, the same source line resolves to <see cref="TestablyAbstractions.MockFileSystem" />
/// and still implements <see cref="IFileSystem" />.
/// </summary>
[Fact]
public async Task ParameterlessConstructor_AfterMigration_IsIFileSystem()
{
TestablyAbstractions.MockFileSystem fileSystem = new();
IFileSystem asInterface = fileSystem;

await That(asInterface).IsNotNull();
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
global using System;
global using System.IO.Abstractions;
global using System.IO.Abstractions.TestingHelpers;
global using System.Threading.Tasks;
global using Xunit;
global using aweXpect;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
// Playground samples deliberately exercise the un-migrated API surface so the analyzer
// and fixer can be developed against them. The fixer-parity check runs over this file's
// content via the code-fix pipeline, not the normal build, so the in-source diagnostic
// is suppressed here to keep static-analysis dashboards quiet.
#pragma warning disable TestablyAbstractionsMigration001

using System.IO.Abstractions.TestingHelpers;

namespace Testably.Abstractions.Migration.SystemIOAbstractionsPlayground;

/// <summary>
Expand All @@ -7,6 +15,11 @@ namespace Testably.Abstractions.Migration.SystemIOAbstractionsPlayground;
/// </summary>
public class MockFileSystemSamples
{
// TODO: Add sample call sites for `new MockFileSystem(...)`, `AddFile(...)`,
// `AddDirectory(...)`, `GetFile(...)`, `MockFileData` initializers, etc.
// Phase 1: parameterless `new MockFileSystem()`. The analyzer flags this; the code fix
// rewrites the file's usings to bind `MockFileSystem` to the Testably namespace.
public static IFileSystem Parameterless()
{
MockFileSystem fileSystem = new();
return fileSystem;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Playground samples deliberately exercise the un-migrated API surface so the analyzer
// and fixer can be developed against them. The fixer-parity check runs over this file's
// content via the code-fix pipeline, not the normal build, so the in-source diagnostic
// is suppressed here to keep static-analysis dashboards quiet.
#pragma warning disable TestablyAbstractionsMigration001

using System.IO.Abstractions.TestingHelpers;

namespace Testably.Abstractions.Migration.SystemIOAbstractionsPlayground;

/// <summary>
/// Smoke test for the path-semantics divergence risk identified in Phase 1: rooted
/// Unix-style paths (e.g. <c>/etc/hosts</c>) are accepted by
/// <see cref="MockFileSystem" /> on Windows but may behave differently under
/// <see cref="Testably.Abstractions.Testing.MockFileSystem" />.
/// </summary>
/// <remarks>
/// The class is a runnable executable in the playground. The body deliberately performs
/// a read after a write so the smoke test surfaces any divergence as a test failure
/// rather than a silent regression.
/// </remarks>
public static class UnixPathSmokeTest
{
public const string UnixStylePath = "/etc/hosts";
public const string Contents = "127.0.0.1 localhost";

public static string RoundTrip()
{
MockFileSystem fileSystem = new();
fileSystem.File.WriteAllText(UnixStylePath, Contents);
return fileSystem.File.ReadAllText(UnixStylePath);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
global using System;
global using System.Collections.Generic;
global using System.IO.Abstractions;
global using System.IO.Abstractions.TestingHelpers;
global using System.Threading;
global using System.Threading.Tasks;
global using Xunit;
Expand Down
Loading
Loading