Created
December 26, 2018 13:59
-
-
Save antonKrouglov/07ccc117cb8c30ad8994446a86c062e5 to your computer and use it in GitHub Desktop.
Port of Path.GetRelativePath (.NET Core2) to .NET 4.7.1+
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Licensed to the .NET Foundation under one or more agreements. | |
// The .NET Foundation licenses this file to you under the MIT license. | |
// See the LICENSE file in the project root for more information. | |
//Adapted from https://github.com/dotnet/corefx/blob/master/src/Common/src/CoreLib/System/IO/Path.cs#L697 for Windows | |
// by Anton Krouglov | |
using System.Runtime.CompilerServices; | |
using System.Diagnostics; | |
using System.Text; | |
using Xunit; | |
// ReSharper disable once CheckNamespace | |
namespace System.IO { | |
// Provides methods for processing file system strings in a cross-platform manner. | |
// Most of the methods don't do a complete parsing (such as examining a UNC hostname), | |
// but they will handle most string operations. | |
public static class PathNetCore { | |
/// <summary> | |
/// Create a relative path from one path to another. Paths will be resolved before calculating the difference. | |
/// Default path comparison for the active platform will be used (OrdinalIgnoreCase for Windows or Mac, Ordinal for Unix). | |
/// </summary> | |
/// <param name="relativeTo">The source path the output should be relative to. This path is always considered to be a directory.</param> | |
/// <param name="path">The destination path.</param> | |
/// <returns>The relative path or <paramref name="path"/> if the paths don't share the same root.</returns> | |
/// <exception cref="ArgumentNullException">Thrown if <paramref name="relativeTo"/> or <paramref name="path"/> is <c>null</c> or an empty string.</exception> | |
public static string GetRelativePath(string relativeTo, string path) { | |
return GetRelativePath(relativeTo, path, StringComparison); | |
} | |
private static string GetRelativePath(string relativeTo, string path, StringComparison comparisonType) { | |
if (string.IsNullOrEmpty(relativeTo)) throw new ArgumentNullException(nameof(relativeTo)); | |
if (string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path)); | |
Debug.Assert(comparisonType == StringComparison.Ordinal || | |
comparisonType == StringComparison.OrdinalIgnoreCase); | |
relativeTo = Path.GetFullPath(relativeTo); | |
path = Path.GetFullPath(path); | |
// Need to check if the roots are different- if they are we need to return the "to" path. | |
if (!PathInternalNetCore.AreRootsEqual(relativeTo, path, comparisonType)) | |
return path; | |
int commonLength = PathInternalNetCore.GetCommonPathLength(relativeTo, path, | |
ignoreCase: comparisonType == StringComparison.OrdinalIgnoreCase); | |
// 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 (PathInternalNetCore.EndsInDirectorySeparator(relativeTo)) | |
relativeToLength--; | |
bool pathEndsInSeparator = PathInternalNetCore.EndsInDirectorySeparator(path); | |
int pathLength = path.Length; | |
if (pathEndsInSeparator) | |
pathLength--; | |
// If we have effectively the same path, return "." | |
if (relativeToLength == pathLength && commonLength >= relativeToLength) return "."; | |
// We have the same root, we need to calculate the difference now using the | |
// common Length and Segment count past the length. | |
// | |
// Some examples: | |
// | |
// C:\Foo C:\Bar L3, S1 -> ..\Bar | |
// C:\Foo C:\Foo\Bar L6, S0 -> Bar | |
// C:\Foo\Bar C:\Bar\Bar L3, S2 -> ..\..\Bar\Bar | |
// C:\Foo\Foo C:\Foo\Bar L7, S1 -> ..\Bar | |
StringBuilder | |
sb = new StringBuilder(); //StringBuilderCache.Acquire(Math.Max(relativeTo.Length, path.Length)); | |
// 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 (PathInternalNetCore.IsDirectorySeparator(relativeTo[i])) { | |
sb.Append(DirectorySeparatorChar); | |
sb.Append(".."); | |
} | |
} | |
} | |
else if (PathInternalNetCore.IsDirectorySeparator(path[commonLength])) { | |
// No parent segments and we need to eat the initial separator | |
// (C:\Foo C:\Foo\Bar case) | |
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, commonLength, differenceLength); | |
} | |
return sb.ToString(); //StringBuilderCache.GetStringAndRelease(sb); | |
} | |
// Public static readonly variant of the separators. The Path implementation itself is using | |
// internal const variant of the separators for better performance. | |
public static readonly char DirectorySeparatorChar = PathInternalNetCore.DirectorySeparatorChar; | |
public static readonly char AltDirectorySeparatorChar = PathInternalNetCore.AltDirectorySeparatorChar; | |
public static readonly char VolumeSeparatorChar = PathInternalNetCore.VolumeSeparatorChar; | |
public static readonly char PathSeparator = PathInternalNetCore.PathSeparator; | |
/// <summary>Returns a comparison that can be used to compare file and directory names for equality.</summary> | |
internal static StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; | |
} | |
/// <summary>Contains internal path helpers that are shared between many projects.</summary> | |
internal static class PathInternalNetCore { | |
internal const char DirectorySeparatorChar = '\\'; | |
internal const char AltDirectorySeparatorChar = '/'; | |
internal const char VolumeSeparatorChar = ':'; | |
internal const char PathSeparator = ';'; | |
internal const string ExtendedDevicePathPrefix = @"\\?\"; | |
internal const string UncPathPrefix = @"\\"; | |
internal const string UncDevicePrefixToInsert = @"?\UNC\"; | |
internal const string UncExtendedPathPrefix = @"\\?\UNC\"; | |
internal const string DevicePathPrefix = @"\\.\"; | |
//internal const int MaxShortPath = 260; | |
// \\?\, \\.\, \??\ | |
internal const int DevicePrefixLength = 4; | |
/// <summary> | |
/// Returns true if the two paths have the same root | |
/// </summary> | |
internal static 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; | |
} | |
/// <summary> | |
/// Gets the length of the root of the path (drive, share, etc.). | |
/// </summary> | |
internal static int GetRootLength(string path) { | |
int i = 0; | |
int volumeSeparatorLength = 2; // Length to the colon "C:" | |
int uncRootLength = 2; // Length to the start of the server name "\\" | |
bool extendedSyntax = path.StartsWith(ExtendedDevicePathPrefix); | |
bool extendedUncSyntax = path.StartsWith(UncExtendedPathPrefix); | |
if (extendedSyntax) { | |
// Shift the position we look for the root from to account for the extended prefix | |
if (extendedUncSyntax) { | |
// "\\" -> "\\?\UNC\" | |
uncRootLength = UncExtendedPathPrefix.Length; | |
} | |
else { | |
// "C:" -> "\\?\C:" | |
volumeSeparatorLength += ExtendedDevicePathPrefix.Length; | |
} | |
} | |
if ((!extendedSyntax || extendedUncSyntax) && path.Length > 0 && IsDirectorySeparator(path[0])) { | |
// UNC or simple rooted path (e.g. "\foo", NOT "\\?\C:\foo") | |
i = 1; // Drive rooted (\foo) is one character | |
if (extendedUncSyntax || (path.Length > 1 && IsDirectorySeparator(path[1]))) { | |
// UNC (\\?\UNC\ or \\), scan past the next two directory separators at most | |
// (e.g. to \\?\UNC\Server\Share or \\Server\Share\) | |
i = uncRootLength; | |
int n = 2; // Maximum separators to skip | |
while (i < path.Length && (!IsDirectorySeparator(path[i]) || --n > 0)) i++; | |
} | |
} | |
else if (path.Length >= volumeSeparatorLength && | |
path[volumeSeparatorLength - 1] == PathNetCore.VolumeSeparatorChar) { | |
// Path is at least longer than where we expect a colon, and has a colon (\\?\A:, A:) | |
// If the colon is followed by a directory separator, move past it | |
i = volumeSeparatorLength; | |
if (path.Length >= volumeSeparatorLength + 1 && IsDirectorySeparator(path[volumeSeparatorLength])) i++; | |
} | |
return i; | |
} | |
/// <summary> | |
/// True if the given character is a directory separator. | |
/// </summary> | |
[MethodImpl(MethodImplOptions.AggressiveInlining)] | |
internal static bool IsDirectorySeparator(char c) { | |
return c == PathNetCore.DirectorySeparatorChar || c == PathNetCore.AltDirectorySeparatorChar; | |
} | |
/// <summary> | |
/// Get the common path length from the start of the string. | |
/// </summary> | |
internal static int GetCommonPathLength(string first, string second, bool ignoreCase) { | |
int commonChars = EqualStartingCharacterCount(first, second, ignoreCase: ignoreCase); | |
// 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; | |
} | |
/// <summary> | |
/// Gets the count of common characters from the left optionally ignoring case | |
/// </summary> | |
internal static unsafe int EqualStartingCharacterCount(string first, string second, bool ignoreCase) { | |
if (string.IsNullOrEmpty(first) || string.IsNullOrEmpty(second)) return 0; | |
int commonChars = 0; | |
fixed (char* f = first) | |
fixed (char* s = second) { | |
char* l = f; | |
char* r = s; | |
char* leftEnd = l + first.Length; | |
char* rightEnd = r + second.Length; | |
while (l != leftEnd && r != rightEnd | |
&& (*l == *r || (ignoreCase && | |
char.ToUpperInvariant((*l)) == char.ToUpperInvariant((*r))))) { | |
commonChars++; | |
l++; | |
r++; | |
} | |
} | |
return commonChars; | |
} | |
/// <summary> | |
/// Returns true if the path ends in a directory separator. | |
/// </summary> | |
internal static bool EndsInDirectorySeparator(string path) | |
=> path.Length > 0 && IsDirectorySeparator(path[path.Length - 1]); | |
} | |
/// <summary> Tests for PathNetCore.GetRelativePath </summary> | |
public static class GetRelativePathTests { | |
[Theory] | |
[InlineData(@"C:\", @"C:\", @".")] | |
[InlineData(@"C:\a", @"C:\a\", @".")] | |
[InlineData(@"C:\A", @"C:\a\", @".")] | |
[InlineData(@"C:\a\", @"C:\a", @".")] | |
[InlineData(@"C:\", @"C:\b", @"b")] | |
[InlineData(@"C:\a", @"C:\b", @"..\b")] | |
[InlineData(@"C:\a", @"C:\b\", @"..\b\")] | |
[InlineData(@"C:\a\b", @"C:\a", @"..")] | |
[InlineData(@"C:\a\b", @"C:\a\", @"..")] | |
[InlineData(@"C:\a\b\", @"C:\a", @"..")] | |
[InlineData(@"C:\a\b\", @"C:\a\", @"..")] | |
[InlineData(@"C:\a\b\c", @"C:\a\b", @"..")] | |
[InlineData(@"C:\a\b\c", @"C:\a\b\", @"..")] | |
[InlineData(@"C:\a\b\c", @"C:\a", @"..\..")] | |
[InlineData(@"C:\a\b\c", @"C:\a\", @"..\..")] | |
[InlineData(@"C:\a\b\c\", @"C:\a\b", @"..")] | |
[InlineData(@"C:\a\b\c\", @"C:\a\b\", @"..")] | |
[InlineData(@"C:\a\b\c\", @"C:\a", @"..\..")] | |
[InlineData(@"C:\a\b\c\", @"C:\a\", @"..\..")] | |
[InlineData(@"C:\a\", @"C:\b", @"..\b")] | |
[InlineData(@"C:\a", @"C:\a\b", @"b")] | |
[InlineData(@"C:\a", @"C:\A\b", @"b")] | |
[InlineData(@"C:\a", @"C:\b\c", @"..\b\c")] | |
[InlineData(@"C:\a\", @"C:\a\b", @"b")] | |
[InlineData(@"C:\", @"D:\", @"D:\")] | |
[InlineData(@"C:\", @"D:\b", @"D:\b")] | |
[InlineData(@"C:\", @"D:\b\", @"D:\b\")] | |
[InlineData(@"C:\a", @"D:\b", @"D:\b")] | |
[InlineData(@"C:\a\", @"D:\b", @"D:\b")] | |
[InlineData(@"C:\ab", @"C:\a", @"..\a")] | |
[InlineData(@"C:\a", @"C:\ab", @"..\ab")] | |
[InlineData(@"C:\", @"\\LOCALHOST\Share\b", @"\\LOCALHOST\Share\b")] | |
[InlineData(@"\\LOCALHOST\Share\a", @"\\LOCALHOST\Share\b", @"..\b")] | |
//[PlatformSpecific(TestPlatforms.Windows)] // Tests Windows-specific paths | |
public static void GetRelativePath_Windows(string relativeTo, string path, string expected) { | |
string result = PathNetCore.GetRelativePath(relativeTo, path); | |
Assert.Equal(expected, result); | |
// Check that we get the equivalent path when the result is combined with the sources | |
Assert.Equal( | |
Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar), | |
Path.GetFullPath(Path.Combine(Path.GetFullPath(relativeTo), result)) | |
.TrimEnd(Path.DirectorySeparatorChar), | |
ignoreCase: true, | |
// ReSharper disable RedundantArgumentDefaultValue | |
ignoreLineEndingDifferences: false, | |
ignoreWhiteSpaceDifferences: false); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment