diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs
index 73aee07f8..42a38341c 100644
--- a/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs
+++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs
@@ -1,4 +1,6 @@
-namespace Testably.Abstractions.Testing.Helpers;
+using System.Text;
+
+namespace Testably.Abstractions.Testing.Helpers;
internal partial class Execute
{
@@ -43,10 +45,67 @@ public override string GetTempPath()
public override bool IsPathRooted(string? path)
=> path?.Length > 0 && path[0] == '/';
+ ///
+ /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L22
+ ///
+ protected override int GetRootLength(string path)
+ {
+ return path.Length > 0 && IsDirectorySeparator(path[0]) ? 1 : 0;
+ }
+
///
/// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L27
///
protected override bool IsDirectorySeparator(char c)
=> c == DirectorySeparatorChar;
+
+ ///
+ /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L89
+ ///
+ protected override bool IsEffectivelyEmpty(string path)
+ => string.IsNullOrEmpty(path);
+
+ ///
+ /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L39
+ ///
+ protected override string NormalizeDirectorySeparators(string path)
+ {
+ bool IsAlreadyNormalized()
+ {
+ for (int i = 0; i < path.Length - 1; i++)
+ {
+ if (IsDirectorySeparator(path[i]) &&
+ IsDirectorySeparator(path[i + 1]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ if (IsAlreadyNormalized())
+ {
+ return path;
+ }
+
+ StringBuilder builder = new(path.Length);
+
+ for (int j = 0; j < path.Length - 1; j++)
+ {
+ char current = path[j];
+
+ if (IsDirectorySeparator(current)
+ && IsDirectorySeparator(path[j + 1]))
+ {
+ continue;
+ }
+
+ builder.Append(current);
+ }
+
+ builder.Append(path[path.Length - 1]);
+ return builder.ToString();
+ }
}
}
diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs
index e556b852e..3c43a27e0 100644
--- a/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs
+++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs
@@ -161,7 +161,31 @@ public ReadOnlySpan GetDirectoryName(ReadOnlySpan path)
///
public string? GetDirectoryName(string? path)
- => System.IO.Path.GetDirectoryName(path);
+ {
+ if (path == null || IsEffectivelyEmpty(path))
+ {
+ return null;
+ }
+
+ int rootLength = GetRootLength(path);
+ if (path.Length <= rootLength)
+ {
+ return null;
+ }
+
+ int end = path.Length;
+ while (end > rootLength && !IsDirectorySeparator(path[end - 1]))
+ {
+ end--;
+ }
+
+ while (end > rootLength && IsDirectorySeparator(path[end - 1]))
+ {
+ end--;
+ }
+
+ return NormalizeDirectorySeparators(path.Substring(0, end));
+ }
#if FEATURE_SPAN
///
@@ -447,7 +471,9 @@ public bool TryJoin(ReadOnlySpan path1,
private static string CombineInternal(string[] paths)
=> System.IO.Path.Combine(paths);
+ protected abstract int GetRootLength(string path);
protected abstract bool IsDirectorySeparator(char c);
+ protected abstract bool IsEffectivelyEmpty(string path);
#if FEATURE_PATH_JOIN || FEATURE_PATH_ADVANCED
private string JoinInternal(string?[] paths)
@@ -489,6 +515,8 @@ private string JoinInternal(string?[] paths)
}
#endif
+ protected abstract string NormalizeDirectorySeparators(string path);
+
protected string RandomString(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyz0123456789";
diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs
index eb0187083..17502e624 100644
--- a/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs
+++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs
@@ -1,4 +1,6 @@
using System;
+using System.Linq;
+using System.Text;
namespace Testably.Abstractions.Testing.Helpers;
@@ -63,17 +65,191 @@ public override bool IsPathRooted(string? path)
(length >= 2 && IsValidDriveChar(path![0]) && path[1] == VolumeSeparatorChar);
}
+ ///
+ /// https://github.com/dotnet/runtime/blob/v8.0.3/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L181
+ ///
+ protected override int GetRootLength(string path)
+ {
+ bool IsDeviceUNC(string p)
+ => p.Length >= 8
+ && IsDevice(p)
+ && IsDirectorySeparator(p[7])
+ && p[4] == 'U'
+ && p[5] == 'N'
+ && p[6] == 'C';
+
+ bool IsDevice(string p)
+ => IsExtended(p)
+ ||
+ (
+ p.Length >= 4
+ && IsDirectorySeparator(p[0])
+ && IsDirectorySeparator(p[1])
+ && (p[2] == '.' || p[2] == '?')
+ && IsDirectorySeparator(p[3])
+ );
+
+ bool IsExtended(string p)
+ => p.Length >= 4
+ && p[0] == '\\'
+ && (p[1] == '\\' || p[1] == '?')
+ && p[2] == '?'
+ && p[3] == '\\';
+
+ int pathLength = path.Length;
+
+ if (pathLength > 0 && IsDirectorySeparator(path[0]))
+ {
+ bool deviceSyntax = IsDevice(path);
+ bool deviceUnc = deviceSyntax && IsDeviceUNC(path);
+
+ if (deviceSyntax && !deviceUnc)
+ {
+ return GetRootLengthWithDeviceSyntax(path);
+ }
+
+ // UNC or simple rooted path (e.g. "\foo", NOT "\\?\C:\foo")
+ if (deviceUnc || (path.Length > 1 && IsDirectorySeparator(path[1])))
+ {
+ return GetRootLengthWithDeviceUncSyntax(path, deviceUnc);
+ }
+
+ // Current drive rooted (e.g. "\foo")
+ return 1;
+ }
+
+ if (pathLength >= 2
+ && path[1] == ':'
+ && IsValidDriveChar(path[0]))
+ {
+ // If the colon is followed by a directory separator, move past it (e.g "C:\")
+ if (pathLength > 2 && IsDirectorySeparator(path[2]))
+ {
+ return 3;
+ }
+
+ // Valid drive specified path ("C:", "D:", etc.)
+ return 2;
+ }
+
+ return 0;
+ }
+
+ private int GetRootLengthWithDeviceSyntax(string path)
+ {
+ // Device path (e.g. "\\?\.", "\\.\")
+ // Skip any characters following the prefix that aren't a separator
+ int i = 4;
+ while (i < path.Length && !IsDirectorySeparator(path[i]))
+ {
+ i++;
+ }
+
+ // If there is another separator take it, as long as we have had at least one
+ // non-separator after the prefix (e.g. don't take "\\?\\", but take "\\?\a\")
+ if (i < path.Length && i > 4 && IsDirectorySeparator(path[i]))
+ {
+ i++;
+ }
+
+ return i;
+ }
+
+ private int GetRootLengthWithDeviceUncSyntax(string path,
+ bool deviceUnc)
+ {
+ // Start past the prefix ("\\" or "\\?\UNC\")
+ int i = deviceUnc ? 8 : 2;
+
+ // Skip two separators at most
+ int n = 2;
+ while (i < path.Length && (!IsDirectorySeparator(path[i]) || --n > 0))
+ {
+ i++;
+ }
+
+ return i;
+ }
+
///
/// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L280
///
protected override bool IsDirectorySeparator(char c)
=> c == DirectorySeparatorChar || c == AltDirectorySeparatorChar;
+ ///
+ /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L381
+ ///
+ protected override bool IsEffectivelyEmpty(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ return true;
+ }
+
+ return path.All(c => c == ' ');
+ }
+
///
/// Returns true if the given character is a valid drive letter
///
/// https://github.com/dotnet/runtime/blob/v8.0.3/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L72
private static bool IsValidDriveChar(char value)
=> (uint)((value | 0x20) - 'a') <= 'z' - 'a';
+
+ ///
+ /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L318
+ ///
+ protected override string NormalizeDirectorySeparators(string path)
+ {
+ bool IsAlreadyNormalized()
+ {
+ for (int i = 1; i < path.Length; i++)
+ {
+ char current = path[i];
+ if (IsDirectorySeparator(current)
+ && (current != DirectorySeparatorChar
+ || (i + 1 < path.Length && IsDirectorySeparator(path[i + 1]))))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ if (IsAlreadyNormalized())
+ {
+ return path;
+ }
+
+ StringBuilder builder = new();
+
+ int start = 0;
+ if (IsDirectorySeparator(path[start]))
+ {
+ start++;
+ builder.Append(DirectorySeparatorChar);
+ }
+
+ for (int i = start; i < path.Length; i++)
+ {
+ char current = path[i];
+
+ if (IsDirectorySeparator(current))
+ {
+ if (i + 1 < path.Length && IsDirectorySeparator(path[i + 1]))
+ {
+ continue;
+ }
+
+ current = DirectorySeparatorChar;
+ }
+
+ builder.Append(current);
+ }
+
+ return builder.ToString();
+ }
}
}
diff --git a/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs b/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs
index adfe34e6c..65e155895 100644
--- a/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs
+++ b/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs
@@ -233,6 +233,7 @@ private bool IncludeSimulatedTests(ClassModel @class)
[
"ChangeExtensionTests",
"EndsInDirectorySeparatorTests",
+ "GetDirectoryNameTests",
"GetExtensionTests",
"GetFileNameTests",
"GetFileNameWithoutExtensionTests",
diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetDirectoryNameTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetDirectoryNameTests.cs
index e15b233ac..1a869cba2 100644
--- a/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetDirectoryNameTests.cs
+++ b/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetDirectoryNameTests.cs
@@ -58,6 +58,8 @@ public void GetDirectoryName_Spaces_ShouldReturnNullOnWindowsOtherwiseEmpty(stri
[SkippableTheory]
[InlineData("\t")]
[InlineData("\n")]
+ [InlineData(" \t")]
+ [InlineData("\n ")]
public void GetDirectoryName_TabOrNewline_ShouldReturnEmptyString(string? path)
{
string? result = FileSystem.Path.GetDirectoryName(path);
@@ -79,6 +81,37 @@ public void GetDirectoryName_ShouldReturnDirectory(
result.Should().Be(directory);
}
+ [SkippableTheory]
+ [AutoData]
+ public void GetDirectoryName_ShouldReplaceAltDirectorySeparator(
+ string parentDirectory, string directory, string filename)
+ {
+ string path = parentDirectory + FileSystem.Path.AltDirectorySeparatorChar + directory +
+ FileSystem.Path.AltDirectorySeparatorChar + filename;
+ string expected = parentDirectory + FileSystem.Path.DirectorySeparatorChar + directory;
+
+ string? result = FileSystem.Path.GetDirectoryName(path);
+
+ result.Should().Be(expected);
+ }
+
+ [SkippableTheory]
+ [InlineData("foo//bar/file", "foo/bar", TestOS.All)]
+ [InlineData("foo///bar/file", "foo/bar", TestOS.All)]
+ [InlineData(@"foo\\bar/file", "foo/bar", TestOS.Windows)]
+ [InlineData(@"foo\\\bar/file", "foo/bar", TestOS.Windows)]
+ public void GetDirectoryName_ShouldNormalizeDirectorySeparators(
+ string path, string expected, TestOS operatingSystem)
+ {
+ Skip.IfNot(Test.RunsOn(operatingSystem));
+
+ expected = expected.Replace('/', FileSystem.Path.DirectorySeparatorChar);
+
+ string? result = FileSystem.Path.GetDirectoryName(path);
+
+ result.Should().Be(expected);
+ }
+
#if FEATURE_SPAN
[SkippableTheory]
[AutoData]
@@ -93,4 +126,64 @@ public void GetDirectoryName_Span_ShouldReturnDirectory(
result.ToString().Should().Be(directory);
}
#endif
+
+ [SkippableTheory]
+ [InlineData("//", null, TestOS.Windows)]
+ [InlineData(@"\\", null, TestOS.Windows)]
+ [InlineData(@"\\", "", TestOS.Linux | TestOS.Mac)]
+ [InlineData(@"\", "", TestOS.Linux | TestOS.Mac)]
+ [InlineData(@"/", null, TestOS.Linux | TestOS.Mac)]
+ [InlineData(@"/a", "/", TestOS.Linux | TestOS.Mac)]
+ [InlineData(@"/a\b", @"/", TestOS.Linux | TestOS.Mac)]
+ [InlineData(@"/a\b/c", @"/a\b", TestOS.Linux | TestOS.Mac)]
+ [InlineData(@"/a/b/c", @"/a/b", TestOS.Linux | TestOS.Mac)]
+ [InlineData(@"/a/b", "/a", TestOS.Linux | TestOS.Mac)]
+ [InlineData("//?/G:/", null, TestOS.Windows)]
+ [InlineData("/??/H:/", @"\??\H:", TestOS.Windows)]
+ [InlineData("//?/I:/a", @"\\?\I:\", TestOS.Windows)]
+ [InlineData("/??/J:/a", @"\??\J:", TestOS.Windows)]
+ [InlineData(@"\\?\K:\", null, TestOS.Windows)]
+ [InlineData(@"\??\L:\", null, TestOS.Windows)]
+ [InlineData(@"\\?\M:\a", @"\\?\M:\", TestOS.Windows)]
+ [InlineData(@"\??\N:\a", @"\??\N:\", TestOS.Windows)]
+ [InlineData(@"\\?\UNC\", null, TestOS.Windows)]
+ [InlineData(@"//?/UNC/", null, TestOS.Windows)]
+ [InlineData(@"\??\UNC\", null, TestOS.Windows)]
+ [InlineData(@"/??/UNC/", @"\??\UNC", TestOS.Windows)]
+ [InlineData(@"\\?\UNC\a", null, TestOS.Windows)]
+ [InlineData(@"//?/UNC/a", null, TestOS.Windows)]
+ [InlineData(@"\??\UNC\a", null, TestOS.Windows)]
+ [InlineData(@"/??/UNC/a", @"\??\UNC", TestOS.Windows)]
+ [InlineData(@"\\?\ABC\", null, TestOS.Windows)]
+ [InlineData(@"//?/ABC/", null, TestOS.Windows)]
+ [InlineData(@"\??\XYZ\", null, TestOS.Windows)]
+ [InlineData(@"/??/XYZ/", @"\??\XYZ", TestOS.Windows)]
+ [InlineData(@"\\?\unc\a", @"\\?\unc\", TestOS.Windows)]
+ [InlineData(@"//?/unc/a", @"\\?\unc\", TestOS.Windows)]
+ [InlineData(@"\??\unc\a", @"\??\unc\", TestOS.Windows)]
+ [InlineData(@"/??/unc/a", @"\??\unc", TestOS.Windows)]
+ [InlineData("//./", null, TestOS.Windows)]
+ [InlineData(@"\\.\", null, TestOS.Windows)]
+ [InlineData("//?/", null, TestOS.Windows)]
+ [InlineData(@"\\?\", null, TestOS.Windows)]
+ [InlineData("//a/", null, TestOS.Windows)]
+ [InlineData(@"\\a\", null, TestOS.Windows)]
+ [InlineData(@"C:", null, TestOS.Windows)]
+ [InlineData(@"D:\", null, TestOS.Windows)]
+ [InlineData(@"E:/", null, TestOS.Windows)]
+ [InlineData(@"F:\a", @"F:\", TestOS.Windows)]
+ [InlineData(@"F:\b\c", @"F:\b", TestOS.Windows)]
+ [InlineData(@"F:\d/e", @"F:\d", TestOS.Windows)]
+ [InlineData(@"G:/f", @"G:\", TestOS.Windows)]
+ [InlineData(@"F:/g\h", @"F:\g", TestOS.Windows)]
+ [InlineData(@"G:/i/j", @"G:\i", TestOS.Windows)]
+ public void GetDirectoryName_SpecialCases_ShouldReturnExpectedValue(
+ string path, string? expected, TestOS operatingSystem)
+ {
+ Skip.IfNot(Test.RunsOn(operatingSystem));
+
+ string? result = FileSystem.Path.GetDirectoryName(path);
+
+ result.Should().Be(expected);
+ }
}
diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/Path/IsPathRootedTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/Path/IsPathRootedTests.cs
index facaf6542..d70701eb8 100644
--- a/Tests/Testably.Abstractions.Tests/FileSystem/Path/IsPathRootedTests.cs
+++ b/Tests/Testably.Abstractions.Tests/FileSystem/Path/IsPathRootedTests.cs
@@ -38,28 +38,28 @@ public static TheoryData TestData()
"/", TestOS.All
},
{
- @"\", TestOS.Windows
+ @"\", TestOS.Windows | TestOS.Framework
},
{
"/foo", TestOS.All
},
{
- @"\foo", TestOS.Windows
+ @"\foo", TestOS.Windows | TestOS.Framework
},
{
"foo/bar", TestOS.None
},
{
- "a:", TestOS.Windows
+ "a:", TestOS.Windows | TestOS.Framework
},
{
- "z:", TestOS.Windows
+ "z:", TestOS.Windows | TestOS.Framework
},
{
- "A:", TestOS.Windows
+ "A:", TestOS.Windows | TestOS.Framework
},
{
- "Z:", TestOS.Windows
+ "Z:", TestOS.Windows | TestOS.Framework
},
{
"@:", TestOS.Framework
diff --git a/Tests/Testably.Abstractions.Tests/TestHelpers/TestExtensions.cs b/Tests/Testably.Abstractions.Tests/TestHelpers/TestExtensions.cs
index e31a3971b..be04d276a 100644
--- a/Tests/Testably.Abstractions.Tests/TestHelpers/TestExtensions.cs
+++ b/Tests/Testably.Abstractions.Tests/TestHelpers/TestExtensions.cs
@@ -20,6 +20,6 @@ public static T DependsOnOS(this Test test, T windows, T macOS, T linux)
public static bool RunsOn(this Test test, TestOS operatingSystem)
=> (operatingSystem.HasFlag(TestOS.Linux) && test.RunsOnLinux) ||
(operatingSystem.HasFlag(TestOS.Mac) && test.RunsOnMac) ||
- (operatingSystem.HasFlag(TestOS.Windows) && test.RunsOnWindows) ||
+ (operatingSystem.HasFlag(TestOS.Windows) && test is { RunsOnWindows: true, IsNetFramework: false }) ||
(operatingSystem.HasFlag(TestOS.Framework) && test.IsNetFramework);
}