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 @@ -840,9 +840,19 @@ private static async Task<Document> ApplyFilesCtorRewriteAsync(

string variableName = localDecl.Declaration.Variables[0].Identifier.Text;
(string indentation, string newline) = DetectIndentationAndNewline(localDecl);
List<StatementSyntax> followUps = entries
.SelectMany(entry => BuildFollowUpStatements(variableName, entry, indentation, newline))
.ToList();
HashSet<string> emittedParents = new(System.StringComparer.Ordinal);
List<StatementSyntax> followUps = [];
foreach (DictionaryEntryShape entry in entries)
{
StatementSyntax? parentStatement = TryBuildParentDirectoryStatement(
variableName, entry, emittedParents, indentation, newline);
if (parentStatement is not null)
{
followUps.Add(parentStatement);
}

followUps.AddRange(BuildFollowUpStatements(variableName, entry, indentation, newline));
}

SyntaxList<StatementSyntax> updatedStatements = block!.Statements;
int index = updatedStatements.IndexOf(localDecl);
Expand Down Expand Up @@ -943,6 +953,76 @@ private static bool TryGetCreationInLocalDecl(
return result;
}

private static StatementSyntax? TryBuildParentDirectoryStatement(
string receiverName,
DictionaryEntryShape entry,
HashSet<string> emittedParents,
string indentation,
string newline)
{
// Only literal string keys can be resolved at fix time. Non-literal keys
// (e.g. variables, interpolations) would require a runtime helper — the
// caller is left to add a CreateDirectory manually for those, as the
// original code worked.
if (entry.Key is not LiteralExpressionSyntax literal
|| !literal.IsKind(SyntaxKind.StringLiteralExpression))
{
return null;
}

string? parent = TryGetParentDirectory(literal.Token.ValueText);
if (parent is null || !emittedParents.Add(parent))
{
return null;
}

string parentLiteralText = SymbolDisplay.FormatLiteral(parent, quote: true);
return SyntaxFactory.ParseStatement(
$"{indentation}{receiverName}.Directory.CreateDirectory({parentLiteralText});{newline}");
}

private static string? TryGetParentDirectory(string path)
{
int lastSep = -1;
for (int i = path.Length - 1; i >= 0; i--)
{
if (path[i] == '/' || path[i] == '\\')
{
lastSep = i;
break;
}
}

if (lastSep < 0)
{
return null;
}

// Collapse trailing duplicate separators ("/foo//file" → parent "/foo").
while (lastSep > 0 && (path[lastSep - 1] == '/' || path[lastSep - 1] == '\\'))
{
lastSep--;
}

string parent = path.Substring(0, lastSep);
if (parent.Length == 0)
{
// Posix-style root ("/file.txt") — root always exists, nothing to create.
return null;
}

// Windows drive root ("C:" or "C:\" already collapsed to "C:") — skip.
if (parent.Length == 2 && parent[1] == ':' && IsAsciiLetter(parent[0]))
{
return null;
}
Comment on lines +1014 to +1018

return parent;
}

private static bool IsAsciiLetter(char c)
=> (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');

private static IEnumerable<StatementSyntax> BuildFollowUpStatements(
string receiverName, DictionaryEntryShape entry, string indentation, string newline)
{
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ public async Task ExpectedMigrationResult()
new(o => o.UseCurrentDirectory("/sandbox"));

// Files + Options expansion: options lambda + per-entry write/attribute follow-ups.
// Note: the dictionary constructor auto-created parent directories; the code fix
// emits write calls only, so any parent directory must be created explicitly
// after migration.
// The dictionary constructor auto-created parent directories; the code fix
// preserves that by emitting one CreateDirectory per unique non-root parent
// before the write calls.
Testably.Abstractions.Testing.MockFileSystem seeded =
new(o => o.UseCurrentDirectory("/work"));
seeded.Directory.CreateDirectory("/work");
Expand All @@ -88,6 +88,7 @@ public async Task ExpectedMigrationResult()
seeded.File.WriteAllText("/work/readonly.txt", "readonly");
seeded.File.SetAttributes("/work/readonly.txt", FileAttributes.ReadOnly);


// Accessor methods migrated onto the IFileSystem surface.
fs.Directory.CreateDirectory("/foo");
fs.File.Create("/foo/empty.txt").Dispose();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,142 @@ await Verifier.VerifyCodeFixAsync(
source);
}

[Fact]
public async Task AddDrive_LocalVariableReceiver_ShouldRewriteToWithDrive()
{
// Local-variable receiver: declarator syntax is VariableDeclaratorSyntax.
// The `var` type annotation parses to an unqualified IdentifierName, so the
// using-swap can safely retarget the inferred MockFileSystem.
Comment on lines +662 to +664
const string source = """
using System.IO.Abstractions.TestingHelpers;

public class C
{
public void Run(MockFileSystem source)
{
var fs = source;
{|#0:fs.AddDrive("D:", new MockDriveData())|};
}
}
""";

const string fixedSource = """
using Testably.Abstractions.Testing;

public class C
{
public void Run(MockFileSystem source)
{
var fs = source;
fs.WithDrive("D:");
}
}
""";

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

[Fact]
public async Task AddDrive_PropertyReceiver_ShouldRewriteToWithDrive()
{
// Property-typed receiver: declarator syntax is PropertyDeclarationSyntax.
// The declared type is unqualified MockFileSystem, so the using-swap retargets
// it correctly.
const string source = """
using System.IO.Abstractions.TestingHelpers;

public class C
{
public MockFileSystem Fs { get; set; } = null!;
public void Run() => {|#0:Fs.AddDrive("D:", new MockDriveData())|};
}
""";

const string fixedSource = """
using Testably.Abstractions.Testing;

public class C
{
public MockFileSystem Fs { get; set; } = null!;
public void Run() => Fs.WithDrive("D:");
}
""";

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

[Fact]
public async Task AddDrive_MethodReturnReceiver_ShouldRewriteToWithDrive()
{
// Method-return-typed receiver: declarator syntax is MethodDeclarationSyntax.
// The return type is unqualified MockFileSystem, so the using-swap retargets
// the call site.
const string source = """
using System.IO.Abstractions.TestingHelpers;

public class C
{
public MockFileSystem GetFs() => null!;
public void Run() => {|#0:GetFs().AddDrive("D:", new MockDriveData())|};
}
""";

const string fixedSource = """
using Testably.Abstractions.Testing;

public class C
{
public MockFileSystem GetFs() => null!;
public void Run() => GetFs().WithDrive("D:");
}
""";

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

[Fact]
public async Task AddDrive_NullablePropertyReceiver_ShouldRewriteToWithDrive()
{
// Nullable-annotated property type: PropertyDeclarationSyntax.Type is a
// NullableTypeSyntax wrapping IdentifierName. The retargetability check
// recurses through the nullable wrapper.
const string source = """
#nullable enable
using System.IO.Abstractions.TestingHelpers;

public class C
{
public MockFileSystem? Fs { get; set; }
public void Run() => {|#0:Fs!.AddDrive("D:", new MockDriveData())|};
Comment on lines +773 to +774
}
""";

const string fixedSource = """
#nullable enable
using Testably.Abstractions.Testing;

public class C
{
public MockFileSystem? Fs { get; set; }
public void Run() => Fs!.WithDrive("D:");
}
""";

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

[Fact]
public async Task AddDrive_AliasQualifiedReceiverDeclaration_HasNoFix()
{
Expand Down
Loading
Loading