Skip to content

Commit

Permalink
feat: implement GetRelativePath for simulated Path (#575)
Browse files Browse the repository at this point in the history
Implement the `GetRelativePath` methods for `Path`.
  • Loading branch information
vbreuss authored Apr 21, 2024
1 parent 6d984e2 commit baa1736
Show file tree
Hide file tree
Showing 3 changed files with 194 additions and 2 deletions.
168 changes: 166 additions & 2 deletions Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,49 @@ public string GetRelativePath(string relativeTo, string path)
relativeTo = fileSystem.Execute.Path.GetFullPath(relativeTo);
path = fileSystem.Execute.Path.GetFullPath(path);

return System.IO.Path.GetRelativePath(relativeTo, path);
// Need to check if the roots are different- if they are we need to return the "to" path.
if (!AreRootsEqual(relativeTo, path, fileSystem.Execute.StringComparisonMode))
{
return path;
}

Func<char, char, bool> charComparer = (c1, c2) => c1 == c2;
if (fileSystem.Execute.StringComparisonMode == StringComparison.OrdinalIgnoreCase)
{
charComparer = (c1, c2) => char.ToUpperInvariant(c1) == char.ToUpperInvariant(c2);
}

int commonLength = GetCommonPathLength(relativeTo, path, charComparer);

// If there is nothing in common they can't share the same root, return the "to" path as is.
if (commonLength == 0)
{
return path;
}

// Trailing separators aren't significant for comparison
int relativeToLength = relativeTo.Length;
if (IsDirectorySeparator(relativeTo[relativeToLength - 1]))
{
relativeToLength--;
}

int pathLength = path.Length;
bool pathEndsInSeparator = IsDirectorySeparator(path[pathLength - 1]);
if (pathEndsInSeparator)
{
pathLength--;
}

// If we have effectively the same path, return "."
if (relativeToLength == pathLength && commonLength >= relativeToLength)
{
return ".";
}

return CreateRelativePath(relativeTo, path,
commonLength, relativeToLength, pathLength,
pathEndsInSeparator);
}
#endif

Expand Down Expand Up @@ -324,7 +366,7 @@ public bool HasExtension([NotNullWhen(true)] string? path)
return false;
}

return TryGetExtensionIndex(path, out var dotIndex)
return TryGetExtensionIndex(path, out int? dotIndex)
&& dotIndex < path.Length - 1;
}

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

#endregion

/// <summary>
/// Returns true if the two paths have the same root
/// </summary>
private bool AreRootsEqual(string first, string second, StringComparison comparisonType)
{
int firstRootLength = GetRootLength(first);
int secondRootLength = GetRootLength(second);

return firstRootLength == secondRootLength
&& string.Compare(
strA: first,
indexA: 0,
strB: second,
indexB: 0,
length: firstRootLength,
comparisonType: comparisonType) == 0;
}

private string CombineInternal(string[] paths)
{
string NormalizePath(string path, bool ignoreStartingSeparator)
Expand Down Expand Up @@ -524,6 +584,110 @@ string NormalizePath(string path, bool ignoreStartingSeparator)
return sb.ToString();
}

/// <summary>
/// We have the same root, we need to calculate the difference now using the
/// common Length and Segment count past the length.
/// </summary>
/// <remarks>
/// Some examples:
/// <para />
/// C:\Foo C:\Bar L3, S1 -> ..\Bar<br />
/// C:\Foo C:\Foo\Bar L6, S0 -> Bar<br />
/// C:\Foo\Bar C:\Bar\Bar L3, S2 -> ..\..\Bar\Bar<br />
/// C:\Foo\Foo C:\Foo\Bar L7, S1 -> ..\Bar<br />
/// </remarks>
private string CreateRelativePath(string relativeTo, string path, int commonLength,
int relativeToLength, int pathLength, bool pathEndsInSeparator)
{
StringBuilder sb = new();

// Add parent segments for segments past the common on the "from" path
if (commonLength < relativeToLength)
{
sb.Append("..");

for (int i = commonLength + 1; i < relativeToLength; i++)
{
if (IsDirectorySeparator(relativeTo[i]))
{
sb.Append(DirectorySeparatorChar);
sb.Append("..");
}
}
}
else if (IsDirectorySeparator(path[commonLength]))
{
// No parent segments, and we need to eat the initial separator
commonLength++;
}

// Now add the rest of the "to" path, adding back the trailing separator
int differenceLength = pathLength - commonLength;
if (pathEndsInSeparator)
{
differenceLength++;
}

if (differenceLength > 0)
{
if (sb.Length > 0)
{
sb.Append(DirectorySeparatorChar);
}

sb.Append(path.Substring(commonLength, differenceLength));
}

return sb.ToString();
}

/// <summary>
/// Get the common path length from the start of the string.
/// </summary>
private int GetCommonPathLength(string first, string second,
Func<char, char, bool> charComparer)
{
int commonChars = 0;
for (; commonChars < first.Length; commonChars++)
{
if (second.Length < commonChars)
{
break;
}

if (!charComparer(first[commonChars], second[commonChars]))
{
break;
}
}

// If nothing matches
if (commonChars == 0)
{
return commonChars;
}

// Or we're a full string and equal length or match to a separator
if (commonChars == first.Length
&& (commonChars == second.Length || IsDirectorySeparator(second[commonChars])))
{
return commonChars;
}

if (commonChars == second.Length && IsDirectorySeparator(first[commonChars]))
{
return commonChars;
}

// It's possible we matched somewhere in the middle of a segment e.g. C:\Foodie and C:\Foobar.
while (commonChars > 0 && !IsDirectorySeparator(first[commonChars - 1]))
{
commonChars--;
}

return commonChars;
}

protected abstract int GetRootLength(string path);
protected abstract bool IsDirectorySeparator(char c);
protected abstract bool IsEffectivelyEmpty(string path);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ private bool IncludeSimulatedTests(ClassModel @class)
"GetFullPathTests",
"GetPathRootTests",
"GetRandomFileNameTests",
"GetRelativePathTests",
"GetTempFileNameTests",
"GetTempPathTests",
"HasExtensionTests",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,33 @@ public void GetRelativePath_DifferentDrives_ShouldReturnAbsolutePath(
result.Should().Be(path2);
}

[SkippableTheory]
[InlineData(@"C:\FOO", @"C:\foo", ".", TestOS.Windows)]
[InlineData("/FOO", "/foo", "../foo", TestOS.Linux)]
[InlineData("/FOO", "/foo", ".", TestOS.Mac)]
[InlineData("foo", "foo/", ".", TestOS.All)]
[InlineData(@"C:\Foo", @"C:\Bar", @"..\Bar", TestOS.Windows)]
[InlineData(@"C:\Foo", @"C:\Foo\Bar", "Bar", TestOS.Windows)]
[InlineData(@"C:\Foo\Bar", @"C:\Bar\Bar", @"..\..\Bar\Bar", TestOS.Windows)]
[InlineData(@"C:\Foo\Foo", @"C:\Foo\Bar", @"..\Bar", TestOS.Windows)]
[InlineData("/Foo", "/Bar", "../Bar", TestOS.Linux | TestOS.Mac)]
[InlineData("/Foo", "/Foo/Bar", "Bar", TestOS.Linux | TestOS.Mac)]
[InlineData("/Foo/Bar", "/Bar/Bar", "../../Bar/Bar", TestOS.Linux | TestOS.Mac)]
[InlineData("/Foo/Foo", "/Foo/Bar", "../Bar", TestOS.Linux | TestOS.Mac)]
public void GetRelativePath_EdgeCases_ShouldReturnExpectedValue(string relativeTo, string path,
string expected, TestOS operatingSystem)
{
Skip.IfNot(Test.RunsOn(operatingSystem));
if (operatingSystem == TestOS.All)
{
expected = expected.Replace('/', FileSystem.Path.DirectorySeparatorChar);
}

string result = FileSystem.Path.GetRelativePath(relativeTo, path);

result.Should().Be(expected);
}

[SkippableFact]
public void GetRelativePath_FromAbsolutePathInCurrentDirectory_ShouldReturnRelativePath()
{
Expand Down

0 comments on commit baa1736

Please sign in to comment.