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
@@ -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,55 @@
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; }
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;
}

INamedTypeSymbol? mockFileData =
compilation.GetTypeByMetadataName(TestingHelpersNamespace + ".MockFileData");
INamedTypeSymbol? mockDriveData =
compilation.GetTypeByMetadataName(TestingHelpersNamespace + ".MockDriveData");
INamedTypeSymbol? mockFileDataAccessor =
compilation.GetTypeByMetadataName(TestingHelpersNamespace + ".IMockFileDataAccessor");

if (mockFileData is null || mockDriveData is null || mockFileDataAccessor is null)
{
return null;
}

return new TestableIoSymbols(mockFileSystem, mockFileData, mockDriveData, mockFileDataAccessor);
Comment thread
vbreuss marked this conversation as resolved.
Outdated
}
}
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,53 @@
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;
}

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

Check warning on line 59 in Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this if statement with the enclosing one.

See more on https://sonarcloud.io/project/issues?id=Testably_Testably.Abstractions.Migration&issues=AZ4nI52c_kr2HotAxNdS&open=AZ4nI52c_kr2HotAxNdS&pullRequest=6
{
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,7 @@
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 +11,45 @@
/// </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();

Check warning on line 28 in Tests/Testably.Abstractions.Migration.Example.Tests/SystemIOAbstractionsMigrationExamples.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=AZ4nI52O_kr2HotAxNdR&open=AZ4nI52O_kr2HotAxNdR&pullRequest=6
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,5 @@
using System.IO.Abstractions.TestingHelpers;

namespace Testably.Abstractions.Migration.SystemIOAbstractionsPlayground;

/// <summary>
Expand All @@ -7,6 +9,11 @@
/// </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();

Check warning on line 16 in Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/MockFileSystemSamples.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=AZ4nI5ys_kr2HotAxNdP&open=AZ4nI5ys_kr2HotAxNdP&pullRequest=6
return fileSystem;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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();

Check warning on line 23 in Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/UnixPathSmokeTest.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=AZ4nI52C_kr2HotAxNdQ&open=AZ4nI52C_kr2HotAxNdQ&pullRequest=6
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