Skip to content

Commit 2932ab1

Browse files
authored
feat: implement GetDirectoryName for simulated Path (#571)
Implement the `GetDirectoryName` methods for `Path`.
1 parent 8eb3fd1 commit 2932ab1

File tree

7 files changed

+366
-9
lines changed

7 files changed

+366
-9
lines changed

Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs

+60-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
namespace Testably.Abstractions.Testing.Helpers;
1+
using System.Text;
2+
3+
namespace Testably.Abstractions.Testing.Helpers;
24

35
internal partial class Execute
46
{
@@ -43,10 +45,67 @@ public override string GetTempPath()
4345
public override bool IsPathRooted(string? path)
4446
=> path?.Length > 0 && path[0] == '/';
4547

48+
/// <summary>
49+
/// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L22
50+
/// </summary>
51+
protected override int GetRootLength(string path)
52+
{
53+
return path.Length > 0 && IsDirectorySeparator(path[0]) ? 1 : 0;
54+
}
55+
4656
/// <summary>
4757
/// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L27
4858
/// </summary>
4959
protected override bool IsDirectorySeparator(char c)
5060
=> c == DirectorySeparatorChar;
61+
62+
/// <summary>
63+
/// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L89
64+
/// </summary>
65+
protected override bool IsEffectivelyEmpty(string path)
66+
=> string.IsNullOrEmpty(path);
67+
68+
/// <summary>
69+
/// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L39
70+
/// </summary>
71+
protected override string NormalizeDirectorySeparators(string path)
72+
{
73+
bool IsAlreadyNormalized()
74+
{
75+
for (int i = 0; i < path.Length - 1; i++)
76+
{
77+
if (IsDirectorySeparator(path[i]) &&
78+
IsDirectorySeparator(path[i + 1]))
79+
{
80+
return false;
81+
}
82+
}
83+
84+
return true;
85+
}
86+
87+
if (IsAlreadyNormalized())
88+
{
89+
return path;
90+
}
91+
92+
StringBuilder builder = new(path.Length);
93+
94+
for (int j = 0; j < path.Length - 1; j++)
95+
{
96+
char current = path[j];
97+
98+
if (IsDirectorySeparator(current)
99+
&& IsDirectorySeparator(path[j + 1]))
100+
{
101+
continue;
102+
}
103+
104+
builder.Append(current);
105+
}
106+
107+
builder.Append(path[path.Length - 1]);
108+
return builder.ToString();
109+
}
51110
}
52111
}

Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs

+29-1
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,31 @@ public ReadOnlySpan<char> GetDirectoryName(ReadOnlySpan<char> path)
161161

162162
/// <inheritdoc cref="IPath.GetDirectoryName(string)" />
163163
public string? GetDirectoryName(string? path)
164-
=> System.IO.Path.GetDirectoryName(path);
164+
{
165+
if (path == null || IsEffectivelyEmpty(path))
166+
{
167+
return null;
168+
}
169+
170+
int rootLength = GetRootLength(path);
171+
if (path.Length <= rootLength)
172+
{
173+
return null;
174+
}
175+
176+
int end = path.Length;
177+
while (end > rootLength && !IsDirectorySeparator(path[end - 1]))
178+
{
179+
end--;
180+
}
181+
182+
while (end > rootLength && IsDirectorySeparator(path[end - 1]))
183+
{
184+
end--;
185+
}
186+
187+
return NormalizeDirectorySeparators(path.Substring(0, end));
188+
}
165189

166190
#if FEATURE_SPAN
167191
/// <inheritdoc cref="IPath.GetExtension(ReadOnlySpan{char})" />
@@ -447,7 +471,9 @@ public bool TryJoin(ReadOnlySpan<char> path1,
447471
private static string CombineInternal(string[] paths)
448472
=> System.IO.Path.Combine(paths);
449473

474+
protected abstract int GetRootLength(string path);
450475
protected abstract bool IsDirectorySeparator(char c);
476+
protected abstract bool IsEffectivelyEmpty(string path);
451477

452478
#if FEATURE_PATH_JOIN || FEATURE_PATH_ADVANCED
453479
private string JoinInternal(string?[] paths)
@@ -489,6 +515,8 @@ private string JoinInternal(string?[] paths)
489515
}
490516
#endif
491517

518+
protected abstract string NormalizeDirectorySeparators(string path);
519+
492520
protected string RandomString(int length)
493521
{
494522
const string chars = "abcdefghijklmnopqrstuvwxyz0123456789";

Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs

+176
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System;
2+
using System.Linq;
3+
using System.Text;
24

35
namespace Testably.Abstractions.Testing.Helpers;
46

@@ -63,17 +65,191 @@ public override bool IsPathRooted(string? path)
6365
(length >= 2 && IsValidDriveChar(path![0]) && path[1] == VolumeSeparatorChar);
6466
}
6567

68+
/// <summary>
69+
/// https://github.com/dotnet/runtime/blob/v8.0.3/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L181
70+
/// </summary>
71+
protected override int GetRootLength(string path)
72+
{
73+
bool IsDeviceUNC(string p)
74+
=> p.Length >= 8
75+
&& IsDevice(p)
76+
&& IsDirectorySeparator(p[7])
77+
&& p[4] == 'U'
78+
&& p[5] == 'N'
79+
&& p[6] == 'C';
80+
81+
bool IsDevice(string p)
82+
=> IsExtended(p)
83+
||
84+
(
85+
p.Length >= 4
86+
&& IsDirectorySeparator(p[0])
87+
&& IsDirectorySeparator(p[1])
88+
&& (p[2] == '.' || p[2] == '?')
89+
&& IsDirectorySeparator(p[3])
90+
);
91+
92+
bool IsExtended(string p)
93+
=> p.Length >= 4
94+
&& p[0] == '\\'
95+
&& (p[1] == '\\' || p[1] == '?')
96+
&& p[2] == '?'
97+
&& p[3] == '\\';
98+
99+
int pathLength = path.Length;
100+
101+
if (pathLength > 0 && IsDirectorySeparator(path[0]))
102+
{
103+
bool deviceSyntax = IsDevice(path);
104+
bool deviceUnc = deviceSyntax && IsDeviceUNC(path);
105+
106+
if (deviceSyntax && !deviceUnc)
107+
{
108+
return GetRootLengthWithDeviceSyntax(path);
109+
}
110+
111+
// UNC or simple rooted path (e.g. "\foo", NOT "\\?\C:\foo")
112+
if (deviceUnc || (path.Length > 1 && IsDirectorySeparator(path[1])))
113+
{
114+
return GetRootLengthWithDeviceUncSyntax(path, deviceUnc);
115+
}
116+
117+
// Current drive rooted (e.g. "\foo")
118+
return 1;
119+
}
120+
121+
if (pathLength >= 2
122+
&& path[1] == ':'
123+
&& IsValidDriveChar(path[0]))
124+
{
125+
// If the colon is followed by a directory separator, move past it (e.g "C:\")
126+
if (pathLength > 2 && IsDirectorySeparator(path[2]))
127+
{
128+
return 3;
129+
}
130+
131+
// Valid drive specified path ("C:", "D:", etc.)
132+
return 2;
133+
}
134+
135+
return 0;
136+
}
137+
138+
private int GetRootLengthWithDeviceSyntax(string path)
139+
{
140+
// Device path (e.g. "\\?\.", "\\.\")
141+
// Skip any characters following the prefix that aren't a separator
142+
int i = 4;
143+
while (i < path.Length && !IsDirectorySeparator(path[i]))
144+
{
145+
i++;
146+
}
147+
148+
// If there is another separator take it, as long as we have had at least one
149+
// non-separator after the prefix (e.g. don't take "\\?\\", but take "\\?\a\")
150+
if (i < path.Length && i > 4 && IsDirectorySeparator(path[i]))
151+
{
152+
i++;
153+
}
154+
155+
return i;
156+
}
157+
158+
private int GetRootLengthWithDeviceUncSyntax(string path,
159+
bool deviceUnc)
160+
{
161+
// Start past the prefix ("\\" or "\\?\UNC\")
162+
int i = deviceUnc ? 8 : 2;
163+
164+
// Skip two separators at most
165+
int n = 2;
166+
while (i < path.Length && (!IsDirectorySeparator(path[i]) || --n > 0))
167+
{
168+
i++;
169+
}
170+
171+
return i;
172+
}
173+
66174
/// <summary>
67175
/// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L280
68176
/// </summary>
69177
protected override bool IsDirectorySeparator(char c)
70178
=> c == DirectorySeparatorChar || c == AltDirectorySeparatorChar;
71179

180+
/// <summary>
181+
/// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L381
182+
/// </summary>
183+
protected override bool IsEffectivelyEmpty(string path)
184+
{
185+
if (string.IsNullOrEmpty(path))
186+
{
187+
return true;
188+
}
189+
190+
return path.All(c => c == ' ');
191+
}
192+
72193
/// <summary>
73194
/// Returns true if the given character is a valid drive letter
74195
/// </summary>
75196
/// <remarks>https://github.com/dotnet/runtime/blob/v8.0.3/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L72</remarks>
76197
private static bool IsValidDriveChar(char value)
77198
=> (uint)((value | 0x20) - 'a') <= 'z' - 'a';
199+
200+
/// <summary>
201+
/// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L318
202+
/// </summary>
203+
protected override string NormalizeDirectorySeparators(string path)
204+
{
205+
bool IsAlreadyNormalized()
206+
{
207+
for (int i = 1; i < path.Length; i++)
208+
{
209+
char current = path[i];
210+
if (IsDirectorySeparator(current)
211+
&& (current != DirectorySeparatorChar
212+
|| (i + 1 < path.Length && IsDirectorySeparator(path[i + 1]))))
213+
{
214+
return false;
215+
}
216+
}
217+
218+
return true;
219+
}
220+
221+
if (IsAlreadyNormalized())
222+
{
223+
return path;
224+
}
225+
226+
StringBuilder builder = new();
227+
228+
int start = 0;
229+
if (IsDirectorySeparator(path[start]))
230+
{
231+
start++;
232+
builder.Append(DirectorySeparatorChar);
233+
}
234+
235+
for (int i = start; i < path.Length; i++)
236+
{
237+
char current = path[i];
238+
239+
if (IsDirectorySeparator(current))
240+
{
241+
if (i + 1 < path.Length && IsDirectorySeparator(path[i + 1]))
242+
{
243+
continue;
244+
}
245+
246+
current = DirectorySeparatorChar;
247+
}
248+
249+
builder.Append(current);
250+
}
251+
252+
return builder.ToString();
253+
}
78254
}
79255
}

Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs

+1
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ private bool IncludeSimulatedTests(ClassModel @class)
233233
[
234234
"ChangeExtensionTests",
235235
"EndsInDirectorySeparatorTests",
236+
"GetDirectoryNameTests",
236237
"GetExtensionTests",
237238
"GetFileNameTests",
238239
"GetFileNameWithoutExtensionTests",

0 commit comments

Comments
 (0)