Skip to content

Commit baa1736

Browse files
authoredApr 21, 2024··
feat: implement GetRelativePath for simulated Path (#575)
Implement the `GetRelativePath` methods for `Path`.
1 parent 6d984e2 commit baa1736

File tree

3 files changed

+194
-2
lines changed

3 files changed

+194
-2
lines changed
 

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

+166-2
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,49 @@ public string GetRelativePath(string relativeTo, string path)
295295
relativeTo = fileSystem.Execute.Path.GetFullPath(relativeTo);
296296
path = fileSystem.Execute.Path.GetFullPath(path);
297297

298-
return System.IO.Path.GetRelativePath(relativeTo, path);
298+
// Need to check if the roots are different- if they are we need to return the "to" path.
299+
if (!AreRootsEqual(relativeTo, path, fileSystem.Execute.StringComparisonMode))
300+
{
301+
return path;
302+
}
303+
304+
Func<char, char, bool> charComparer = (c1, c2) => c1 == c2;
305+
if (fileSystem.Execute.StringComparisonMode == StringComparison.OrdinalIgnoreCase)
306+
{
307+
charComparer = (c1, c2) => char.ToUpperInvariant(c1) == char.ToUpperInvariant(c2);
308+
}
309+
310+
int commonLength = GetCommonPathLength(relativeTo, path, charComparer);
311+
312+
// If there is nothing in common they can't share the same root, return the "to" path as is.
313+
if (commonLength == 0)
314+
{
315+
return path;
316+
}
317+
318+
// Trailing separators aren't significant for comparison
319+
int relativeToLength = relativeTo.Length;
320+
if (IsDirectorySeparator(relativeTo[relativeToLength - 1]))
321+
{
322+
relativeToLength--;
323+
}
324+
325+
int pathLength = path.Length;
326+
bool pathEndsInSeparator = IsDirectorySeparator(path[pathLength - 1]);
327+
if (pathEndsInSeparator)
328+
{
329+
pathLength--;
330+
}
331+
332+
// If we have effectively the same path, return "."
333+
if (relativeToLength == pathLength && commonLength >= relativeToLength)
334+
{
335+
return ".";
336+
}
337+
338+
return CreateRelativePath(relativeTo, path,
339+
commonLength, relativeToLength, pathLength,
340+
pathEndsInSeparator);
299341
}
300342
#endif
301343

@@ -324,7 +366,7 @@ public bool HasExtension([NotNullWhen(true)] string? path)
324366
return false;
325367
}
326368

327-
return TryGetExtensionIndex(path, out var dotIndex)
369+
return TryGetExtensionIndex(path, out int? dotIndex)
328370
&& dotIndex < path.Length - 1;
329371
}
330372

@@ -463,6 +505,24 @@ public bool TryJoin(ReadOnlySpan<char> path1,
463505

464506
#endregion
465507

508+
/// <summary>
509+
/// Returns true if the two paths have the same root
510+
/// </summary>
511+
private bool AreRootsEqual(string first, string second, StringComparison comparisonType)
512+
{
513+
int firstRootLength = GetRootLength(first);
514+
int secondRootLength = GetRootLength(second);
515+
516+
return firstRootLength == secondRootLength
517+
&& string.Compare(
518+
strA: first,
519+
indexA: 0,
520+
strB: second,
521+
indexB: 0,
522+
length: firstRootLength,
523+
comparisonType: comparisonType) == 0;
524+
}
525+
466526
private string CombineInternal(string[] paths)
467527
{
468528
string NormalizePath(string path, bool ignoreStartingSeparator)
@@ -524,6 +584,110 @@ string NormalizePath(string path, bool ignoreStartingSeparator)
524584
return sb.ToString();
525585
}
526586

587+
/// <summary>
588+
/// We have the same root, we need to calculate the difference now using the
589+
/// common Length and Segment count past the length.
590+
/// </summary>
591+
/// <remarks>
592+
/// Some examples:
593+
/// <para />
594+
/// C:\Foo C:\Bar L3, S1 -> ..\Bar<br />
595+
/// C:\Foo C:\Foo\Bar L6, S0 -> Bar<br />
596+
/// C:\Foo\Bar C:\Bar\Bar L3, S2 -> ..\..\Bar\Bar<br />
597+
/// C:\Foo\Foo C:\Foo\Bar L7, S1 -> ..\Bar<br />
598+
/// </remarks>
599+
private string CreateRelativePath(string relativeTo, string path, int commonLength,
600+
int relativeToLength, int pathLength, bool pathEndsInSeparator)
601+
{
602+
StringBuilder sb = new();
603+
604+
// Add parent segments for segments past the common on the "from" path
605+
if (commonLength < relativeToLength)
606+
{
607+
sb.Append("..");
608+
609+
for (int i = commonLength + 1; i < relativeToLength; i++)
610+
{
611+
if (IsDirectorySeparator(relativeTo[i]))
612+
{
613+
sb.Append(DirectorySeparatorChar);
614+
sb.Append("..");
615+
}
616+
}
617+
}
618+
else if (IsDirectorySeparator(path[commonLength]))
619+
{
620+
// No parent segments, and we need to eat the initial separator
621+
commonLength++;
622+
}
623+
624+
// Now add the rest of the "to" path, adding back the trailing separator
625+
int differenceLength = pathLength - commonLength;
626+
if (pathEndsInSeparator)
627+
{
628+
differenceLength++;
629+
}
630+
631+
if (differenceLength > 0)
632+
{
633+
if (sb.Length > 0)
634+
{
635+
sb.Append(DirectorySeparatorChar);
636+
}
637+
638+
sb.Append(path.Substring(commonLength, differenceLength));
639+
}
640+
641+
return sb.ToString();
642+
}
643+
644+
/// <summary>
645+
/// Get the common path length from the start of the string.
646+
/// </summary>
647+
private int GetCommonPathLength(string first, string second,
648+
Func<char, char, bool> charComparer)
649+
{
650+
int commonChars = 0;
651+
for (; commonChars < first.Length; commonChars++)
652+
{
653+
if (second.Length < commonChars)
654+
{
655+
break;
656+
}
657+
658+
if (!charComparer(first[commonChars], second[commonChars]))
659+
{
660+
break;
661+
}
662+
}
663+
664+
// If nothing matches
665+
if (commonChars == 0)
666+
{
667+
return commonChars;
668+
}
669+
670+
// Or we're a full string and equal length or match to a separator
671+
if (commonChars == first.Length
672+
&& (commonChars == second.Length || IsDirectorySeparator(second[commonChars])))
673+
{
674+
return commonChars;
675+
}
676+
677+
if (commonChars == second.Length && IsDirectorySeparator(first[commonChars]))
678+
{
679+
return commonChars;
680+
}
681+
682+
// It's possible we matched somewhere in the middle of a segment e.g. C:\Foodie and C:\Foobar.
683+
while (commonChars > 0 && !IsDirectorySeparator(first[commonChars - 1]))
684+
{
685+
commonChars--;
686+
}
687+
688+
return commonChars;
689+
}
690+
527691
protected abstract int GetRootLength(string path);
528692
protected abstract bool IsDirectorySeparator(char c);
529693
protected abstract bool IsEffectivelyEmpty(string path);

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

+1
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ private bool IncludeSimulatedTests(ClassModel @class)
272272
"GetFullPathTests",
273273
"GetPathRootTests",
274274
"GetRandomFileNameTests",
275+
"GetRelativePathTests",
275276
"GetTempFileNameTests",
276277
"GetTempPathTests",
277278
"HasExtensionTests",

‎Tests/Testably.Abstractions.Tests/FileSystem/Path/GetRelativePathTests.cs

+27
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,33 @@ public void GetRelativePath_DifferentDrives_ShouldReturnAbsolutePath(
3535
result.Should().Be(path2);
3636
}
3737

38+
[SkippableTheory]
39+
[InlineData(@"C:\FOO", @"C:\foo", ".", TestOS.Windows)]
40+
[InlineData("/FOO", "/foo", "../foo", TestOS.Linux)]
41+
[InlineData("/FOO", "/foo", ".", TestOS.Mac)]
42+
[InlineData("foo", "foo/", ".", TestOS.All)]
43+
[InlineData(@"C:\Foo", @"C:\Bar", @"..\Bar", TestOS.Windows)]
44+
[InlineData(@"C:\Foo", @"C:\Foo\Bar", "Bar", TestOS.Windows)]
45+
[InlineData(@"C:\Foo\Bar", @"C:\Bar\Bar", @"..\..\Bar\Bar", TestOS.Windows)]
46+
[InlineData(@"C:\Foo\Foo", @"C:\Foo\Bar", @"..\Bar", TestOS.Windows)]
47+
[InlineData("/Foo", "/Bar", "../Bar", TestOS.Linux | TestOS.Mac)]
48+
[InlineData("/Foo", "/Foo/Bar", "Bar", TestOS.Linux | TestOS.Mac)]
49+
[InlineData("/Foo/Bar", "/Bar/Bar", "../../Bar/Bar", TestOS.Linux | TestOS.Mac)]
50+
[InlineData("/Foo/Foo", "/Foo/Bar", "../Bar", TestOS.Linux | TestOS.Mac)]
51+
public void GetRelativePath_EdgeCases_ShouldReturnExpectedValue(string relativeTo, string path,
52+
string expected, TestOS operatingSystem)
53+
{
54+
Skip.IfNot(Test.RunsOn(operatingSystem));
55+
if (operatingSystem == TestOS.All)
56+
{
57+
expected = expected.Replace('/', FileSystem.Path.DirectorySeparatorChar);
58+
}
59+
60+
string result = FileSystem.Path.GetRelativePath(relativeTo, path);
61+
62+
result.Should().Be(expected);
63+
}
64+
3865
[SkippableFact]
3966
public void GetRelativePath_FromAbsolutePathInCurrentDirectory_ShouldReturnRelativePath()
4067
{

0 commit comments

Comments
 (0)
Please sign in to comment.