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); }