From 5ea9db1c81d2b9ed00e64028c5a3a59a6e9d0ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 15 May 2026 16:16:15 +0200 Subject: [PATCH 1/2] feat: emit parent CreateDirectory in dictionary-ctor fix The MockFileSystem dictionary constructor auto-creates each entry's parent directory. Testably's File.WriteAllText does not, so the rewritten code threw DirectoryNotFoundException at runtime whenever a seeded path had a non-root parent. The code fix now emits one Directory.CreateDirectory call per unique non-root parent before the WriteAll* follow-ups. Literal-string keys only; non-literal keys are left to the user. Root paths ("/file") and Windows drive roots ("C:") are suppressed. --- .../SystemIOAbstractionsCodeFixProvider.cs | 86 +++++++++++- .../SystemIOAbstractionsMigrationExamples.cs | 7 +- ...nsCodeFixProviderTests.ConstructorTests.cs | 127 ++++++++++++++++++ 3 files changed, 214 insertions(+), 6 deletions(-) diff --git a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs index f338d64..b97dd89 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs @@ -840,9 +840,19 @@ private static async Task ApplyFilesCtorRewriteAsync( string variableName = localDecl.Declaration.Variables[0].Identifier.Text; (string indentation, string newline) = DetectIndentationAndNewline(localDecl); - List followUps = entries - .SelectMany(entry => BuildFollowUpStatements(variableName, entry, indentation, newline)) - .ToList(); + HashSet emittedParents = new(System.StringComparer.Ordinal); + List 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 updatedStatements = block!.Statements; int index = updatedStatements.IndexOf(localDecl); @@ -943,6 +953,76 @@ private static bool TryGetCreationInLocalDecl( return result; } + private static StatementSyntax? TryBuildParentDirectoryStatement( + string receiverName, + DictionaryEntryShape entry, + HashSet 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; + } + + return parent; + } + + private static bool IsAsciiLetter(char c) + => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + private static IEnumerable BuildFollowUpStatements( string receiverName, DictionaryEntryShape entry, string indentation, string newline) { diff --git a/Tests/Testably.Abstractions.Migration.Example.Tests/SystemIOAbstractionsMigrationExamples.cs b/Tests/Testably.Abstractions.Migration.Example.Tests/SystemIOAbstractionsMigrationExamples.cs index 132cf76..99f14ba 100644 --- a/Tests/Testably.Abstractions.Migration.Example.Tests/SystemIOAbstractionsMigrationExamples.cs +++ b/Tests/Testably.Abstractions.Migration.Example.Tests/SystemIOAbstractionsMigrationExamples.cs @@ -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"); @@ -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(); diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.ConstructorTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.ConstructorTests.cs index 48fed2a..61912a5 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.ConstructorTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.ConstructorTests.cs @@ -387,6 +387,133 @@ await Verifier.VerifyCodeFixAsync( source); } + [Fact] + public async Task FilesConstructor_NonRootParent_EmitsCreateDirectoryFollowUp() + { + // The `Dictionary` constructor auto-creates each + // entry's parent directory. The Testably API's WriteAllText does not, so + // the fixer emits a CreateDirectory call per unique non-root parent. + const string source = """ + using System.Collections.Generic; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run() + { + var fs = {|#0:new MockFileSystem(new Dictionary + { + ["/etc/hosts"] = new MockFileData("127.0.0.1 localhost"), + })|}; + } + } + """; + + const string fixedSource = """ + using System.Collections.Generic; + using Testably.Abstractions.Testing; + + public class C + { + public void Run() + { + var fs = new MockFileSystem(); + fs.Directory.CreateDirectory("/etc"); + fs.File.WriteAllText("/etc/hosts", "127.0.0.1 localhost"); + } + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task FilesConstructor_SharedParent_EmitsCreateDirectoryOnce() + { + // Two entries share the same parent — only one CreateDirectory is emitted. + const string source = """ + using System.Collections.Generic; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run() + { + var fs = {|#0:new MockFileSystem(new Dictionary + { + ["/work/a.txt"] = new MockFileData("a"), + ["/work/b.txt"] = new MockFileData("b"), + })|}; + } + } + """; + + const string fixedSource = """ + using System.Collections.Generic; + using Testably.Abstractions.Testing; + + public class C + { + public void Run() + { + var fs = new MockFileSystem(); + fs.Directory.CreateDirectory("/work"); + fs.File.WriteAllText("/work/a.txt", "a"); + fs.File.WriteAllText("/work/b.txt", "b"); + } + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task FilesConstructor_NonLiteralKey_SkipsCreateDirectory() + { + // Non-literal keys can't be resolved at fix time. The fixer emits the + // write call but leaves parent-directory creation to the user. + const string source = """ + using System.Collections.Generic; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(string path) + { + var fs = {|#0:new MockFileSystem(new Dictionary + { + [path] = new MockFileData("hello"), + })|}; + } + } + """; + + const string fixedSource = """ + using System.Collections.Generic; + using Testably.Abstractions.Testing; + + public class C + { + public void Run(string path) + { + var fs = new MockFileSystem(); + fs.File.WriteAllText(path, "hello"); + } + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + [Fact] public async Task FilesOptionsConstructor_ShouldFoldOptionsAndExpandEntries() { From a8beb6cc144f0c885e3db8714b0a054affdaa85d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 15 May 2026 16:27:16 +0200 Subject: [PATCH 2/2] test: improve coverage for non-parameter MockFileSystem receivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes unused TypeExtensions.GloballyQualified helpers — they were 0% covered because nothing referenced them. Adds four AddDrive code-fix tests that exercise the previously-untested receiver-declaration branches in IsRetargetableMockFileSystemReceiver: local variable (VariableDeclarator), property (PropertyDeclaration), method-return (MethodDeclaration), and nullable property (NullableType). The existing tests only covered parameter-declared receivers. --- .../Common/TypeExtensions.cs | 36 ----- ...odeFixProviderTests.AccessorMethodTests.cs | 136 ++++++++++++++++++ 2 files changed, 136 insertions(+), 36 deletions(-) delete mode 100644 Source/Testably.Abstractions.Migration.Analyzers/Common/TypeExtensions.cs diff --git a/Source/Testably.Abstractions.Migration.Analyzers/Common/TypeExtensions.cs b/Source/Testably.Abstractions.Migration.Analyzers/Common/TypeExtensions.cs deleted file mode 100644 index 0755e86..0000000 --- a/Source/Testably.Abstractions.Migration.Analyzers/Common/TypeExtensions.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace Testably.Abstractions.Migration.Analyzers.Common; - -internal static class TypeExtensions -{ - private static readonly SymbolDisplayFormat FullyQualifiedNonGenericWithGlobalPrefix = new( - SymbolDisplayGlobalNamespaceStyle.Included, - SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, - SymbolDisplayGenericsOptions.None, - SymbolDisplayMemberOptions.IncludeContainingType, - SymbolDisplayDelegateStyle.NameAndSignature, - SymbolDisplayExtensionMethodStyle.Default, - SymbolDisplayParameterOptions.IncludeType, - SymbolDisplayPropertyStyle.NameOnly, - SymbolDisplayLocalOptions.IncludeType - ); - - private static readonly SymbolDisplayFormat? FullyQualifiedGenericWithGlobalPrefix = new( - SymbolDisplayGlobalNamespaceStyle.Included, - SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, - SymbolDisplayGenericsOptions.None, - SymbolDisplayMemberOptions.IncludeContainingType, - SymbolDisplayDelegateStyle.NameAndSignature, - SymbolDisplayExtensionMethodStyle.Default, - SymbolDisplayParameterOptions.IncludeType, - SymbolDisplayPropertyStyle.NameOnly, - SymbolDisplayLocalOptions.IncludeType - ); - - public static string GloballyQualified(this ISymbol typeSymbol) => - typeSymbol.ToDisplayString(FullyQualifiedGenericWithGlobalPrefix); - - public static string GloballyQualifiedNonGeneric(this ISymbol typeSymbol) => - typeSymbol.ToDisplayString(FullyQualifiedNonGenericWithGlobalPrefix); -} diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs index d2b3c93..cd2027d 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs @@ -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. + 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())|}; + } + """; + + 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() {