// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using System.Text; 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 partial class Path { // 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 = PathInternal.DirectorySeparatorChar; public static readonly char AltDirectorySeparatorChar = PathInternal.AltDirectorySeparatorChar; public static readonly char VolumeSeparatorChar = PathInternal.VolumeSeparatorChar; public static readonly char PathSeparator = PathInternal.PathSeparator; // For generating random file names // 8 random bytes provides 12 chars in our encoding for the 8.3 name. private const int KeyLength = 8; [Obsolete("Path.InvalidPathChars has been deprecated. Use GetInvalidPathChars or GetInvalidFileNameChars instead.")] public static readonly char[] InvalidPathChars = GetInvalidPathChars(); // Changes the extension of a file path. The path parameter // specifies a file path, and the extension parameter // specifies a file extension (with a leading period, such as // ".exe" or ".cs"). // // The function returns a file path with the same root, directory, and base // name parts as path, but with the file extension changed to // the specified extension. If path is null, the function // returns null. If path does not contain a file extension, // the new file extension is appended to the path. If extension // is null, any existing extension is removed from path. [return: NotNullIfNotNull(nameof(path))] public static string? ChangeExtension(string? path, string? extension) { if (path == null) return null; int subLength = path.Length; if (subLength == 0) return string.Empty; for (int i = path.Length - 1; i >= 0; i--) { char ch = path[i]; if (ch == '.') { subLength = i; break; } if (PathInternal.IsDirectorySeparator(ch)) { break; } } if (extension == null) { return path.Substring(0, subLength); } ReadOnlySpan<char> subpath = path.AsSpan(0, subLength); return extension.StartsWith('.') ? string.Concat(subpath, extension) : string.Concat(subpath, ".", extension); } /// <summary> /// Determines whether the specified file or directory exists. /// </summary> /// <remarks> /// Unlike <see cref="File.Exists(string?)"/> it returns true for existing, non-regular files like pipes. /// If the path targets an existing link, but the target of the link does not exist, it returns true. /// </remarks> /// <param name="path">The path to check</param> /// <returns> /// <see langword="true" /> if the caller has the required permissions and <paramref name="path" /> contains /// the name of an existing file or directory; otherwise, <see langword="false" />. /// This method also returns <see langword="false" /> if <paramref name="path" /> is <see langword="null" />, /// an invalid path, or a zero-length string. If the caller does not have sufficient permissions to read the specified path, /// no exception is thrown and the method returns <see langword="false" /> regardless of the existence of <paramref name="path" />. /// </returns> public static bool Exists([NotNullWhen(true)] string? path) { if (string.IsNullOrEmpty(path)) { return false; } string? fullPath; try { fullPath = GetFullPath(path); } catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException) { return false; } bool result = ExistsCore(fullPath, out bool isDirectory); if (result && PathInternal.IsDirectorySeparator(fullPath[^1])) { // Some sys-calls remove all trailing slashes and may give false positives for existing files. // We want to make sure that if the path ends in a trailing slash, it's truly a directory. return isDirectory; } return result; } /// <summary> /// Returns the directory portion of a file path. This method effectively /// removes the last segment of the given file path, i.e. it returns a /// string consisting of all characters up to but not including the last /// backslash ("\") in the file path. The returned value is null if the /// specified path is null, empty, or a root (such as "\", "C:", or /// "\\server\share"). /// </summary> /// <remarks> /// Directory separators are normalized in the returned string. /// </remarks> public static string? GetDirectoryName(string? path) { if (path == null || PathInternal.IsEffectivelyEmpty(path.AsSpan())) return null; int end = GetDirectoryNameOffset(path.AsSpan()); return end >= 0 ? PathInternal.NormalizeDirectorySeparators(path.Substring(0, end)) : null; } /// <summary> /// Returns the directory portion of a file path. The returned value is empty /// if the specified path is null, empty, or a root (such as "\", "C:", or /// "\\server\share"). /// </summary> /// <remarks> /// Unlike the string overload, this method will not normalize directory separators. /// </remarks> public static ReadOnlySpan<char> GetDirectoryName(ReadOnlySpan<char> path) { if (PathInternal.IsEffectivelyEmpty(path)) return ReadOnlySpan<char>.Empty; int end = GetDirectoryNameOffset(path); return end >= 0 ? path.Slice(0, end) : ReadOnlySpan<char>.Empty; } internal static int GetDirectoryNameOffset(ReadOnlySpan<char> path) { int rootLength = PathInternal.GetRootLength(path); int end = path.Length; if (end <= rootLength) return -1; while (end > rootLength && !PathInternal.IsDirectorySeparator(path[--end])) ; // Trim off any remaining separators (to deal with C:\foo\\bar) while (end > rootLength && PathInternal.IsDirectorySeparator(path[end - 1])) end--; return end; } /// <summary> /// Returns the extension of the given path. The returned value includes the period (".") character of the /// extension except when you have a terminal period when you get string.Empty, such as ".exe" or ".cpp". /// The returned value is null if the given path is null or empty if the given path does not include an /// extension. /// </summary> [return: NotNullIfNotNull(nameof(path))] public static string? GetExtension(string? path) { if (path == null) return null; return GetExtension(path.AsSpan()).ToString(); } /// <summary> /// Returns the extension of the given path. /// </summary> /// <remarks> /// The returned value is an empty ReadOnlySpan if the given path does not include an extension. /// </remarks> public static ReadOnlySpan<char> GetExtension(ReadOnlySpan<char> path) { int length = path.Length; for (int i = length - 1; i >= 0; i--) { char ch = path[i]; if (ch == '.') { if (i != length - 1) return path.Slice(i, length - i); else return ReadOnlySpan<char>.Empty; } if (PathInternal.IsDirectorySeparator(ch)) break; } return ReadOnlySpan<char>.Empty; } /// <summary> /// Returns the name and extension parts of the given path. The resulting string contains /// the characters of path that follow the last separator in path. The resulting string is /// null if path is null. /// </summary> [return: NotNullIfNotNull(nameof(path))] public static string? GetFileName(string? path) { if (path == null) return null; ReadOnlySpan<char> result = GetFileName(path.AsSpan()); if (path.Length == result.Length) return path; return result.ToString(); } /// <summary> /// The returned ReadOnlySpan contains the characters of the path that follows the last separator in path. /// </summary> public static ReadOnlySpan<char> GetFileName(ReadOnlySpan<char> path) { int root = GetPathRoot(path).Length; // We don't want to cut off "C:\file.txt:stream" (i.e. should be "file.txt:stream") // but we *do* want "C:Foo" => "Foo". This necessitates checking for the root. int i = PathInternal.DirectorySeparatorChar == PathInternal.AltDirectorySeparatorChar ? path.LastIndexOf(PathInternal.DirectorySeparatorChar) : path.LastIndexOfAny(PathInternal.DirectorySeparatorChar, PathInternal.AltDirectorySeparatorChar); return path.Slice(i < root ? root : i + 1); } [return: NotNullIfNotNull(nameof(path))] public static string? GetFileNameWithoutExtension(string? path) { if (path == null) return null; ReadOnlySpan<char> result = GetFileNameWithoutExtension(path.AsSpan()); if (path.Length == result.Length) return path; return result.ToString(); } /// <summary> /// Returns the characters between the last separator and last (.) in the path. /// </summary> public static ReadOnlySpan<char> GetFileNameWithoutExtension(ReadOnlySpan<char> path) { ReadOnlySpan<char> fileName = GetFileName(path); int lastPeriod = fileName.LastIndexOf('.'); return lastPeriod < 0 ? fileName : // No extension was found fileName.Slice(0, lastPeriod); } /// <summary> /// Returns a cryptographically strong random 8.3 string that can be /// used as either a folder name or a file name. /// </summary> public static unsafe string GetRandomFileName() { byte* pKey = stackalloc byte[KeyLength]; Interop.GetRandomBytes(pKey, KeyLength); return string.Create( 12, (IntPtr)pKey, (span, key) => // 12 == 8 + 1 (for period) + 3 Populate83FileNameFromRandomBytes((byte*)key, KeyLength, span)); } /// <summary> /// Returns true if the path is fixed to a specific drive or UNC path. This method does no /// validation of the path (URIs will be returned as relative as a result). /// Returns false if the path specified is relative to the current drive or working directory. /// </summary> /// <remarks> /// Handles paths that use the alternate directory separator. It is a frequent mistake to /// assume that rooted paths <see cref="IsPathRooted(string)"/> are not relative. This isn't the case. /// "C:a" is drive relative- meaning that it will be resolved against the current directory /// for C: (rooted, but relative). "C:\a" is rooted and not relative (the current directory /// will not be used to modify the path). /// </remarks> /// <exception cref="ArgumentNullException"> /// Thrown if <paramref name="path"/> is null. /// </exception> public static bool IsPathFullyQualified(string path) { ArgumentNullException.ThrowIfNull(path); return IsPathFullyQualified(path.AsSpan()); } public static bool IsPathFullyQualified(ReadOnlySpan<char> path) { return !PathInternal.IsPartiallyQualified(path); } /// <summary> /// Tests if a path's file name includes a file extension. A trailing period /// is not considered an extension. /// </summary> public static bool HasExtension([NotNullWhen(true)] string? path) { if (path != null) { return HasExtension(path.AsSpan()); } return false; } public static bool HasExtension(ReadOnlySpan<char> path) { for (int i = path.Length - 1; i >= 0; i--) { char ch = path[i]; if (ch == '.') { return i != path.Length - 1; } if (PathInternal.IsDirectorySeparator(ch)) break; } return false; } public static string Combine(string path1, string path2) { ArgumentNullException.ThrowIfNull(path1); ArgumentNullException.ThrowIfNull(path2); return CombineInternal(path1, path2); } public static string Combine(string path1, string path2, string path3) { ArgumentNullException.ThrowIfNull(path1); ArgumentNullException.ThrowIfNull(path2); ArgumentNullException.ThrowIfNull(path3); return CombineInternal(path1, path2, path3); } public static string Combine(string path1, string path2, string path3, string path4) { ArgumentNullException.ThrowIfNull(path1); ArgumentNullException.ThrowIfNull(path2); ArgumentNullException.ThrowIfNull(path3); ArgumentNullException.ThrowIfNull(path4); return CombineInternal(path1, path2, path3, path4); } public static string Combine(params string[] paths) { ArgumentNullException.ThrowIfNull(paths); return Combine((ReadOnlySpan<string>)paths); } /// <summary> /// Combines a span of strings into a path. /// </summary> /// <param name="paths">A span of parts of the path.</param> /// <returns>The combined paths.</returns> public static unsafe string Combine(params ReadOnlySpan<string> paths) { int maxSize = 0; int firstComponent = 0; // We have two passes, the first calculates how large a buffer to allocate and does some precondition // checks on the paths passed in. The second actually does the combination. for (int i = 0; i < paths.Length; i++) { ArgumentNullException.ThrowIfNull(paths[i], nameof(paths)); if (paths[i].Length == 0) { continue; } if (IsPathRooted(paths[i])) { firstComponent = i; maxSize = paths[i].Length; } else { maxSize += paths[i].Length; } char ch = paths[i][^1]; if (!PathInternal.IsDirectorySeparator(ch)) maxSize++; } var builder = new ValueStringBuilder(stackalloc char[260]); // MaxShortPath on Windows builder.EnsureCapacity(maxSize); for (int i = firstComponent; i < paths.Length; i++) { if (paths[i].Length == 0) { continue; } if (builder.Length == 0) { builder.Append(paths[i]); } else { char ch = builder[^1]; if (!PathInternal.IsDirectorySeparator(ch)) { builder.Append(PathInternal.DirectorySeparatorChar); } builder.Append(paths[i]); } } return builder.ToString(); } // Unlike Combine(), Join() methods do not consider rooting. They simply combine paths, ensuring that there // is a directory separator between them. public static string Join(ReadOnlySpan<char> path1, ReadOnlySpan<char> path2) { if (path1.Length == 0) return path2.ToString(); if (path2.Length == 0) return path1.ToString(); return JoinInternal(path1, path2); } public static string Join(ReadOnlySpan<char> path1, ReadOnlySpan<char> path2, ReadOnlySpan<char> path3) { if (path1.Length == 0) return Join(path2, path3); if (path2.Length == 0) return Join(path1, path3); if (path3.Length == 0) return Join(path1, path2); return JoinInternal(path1, path2, path3); } public static string Join(ReadOnlySpan<char> path1, ReadOnlySpan<char> path2, ReadOnlySpan<char> path3, ReadOnlySpan<char> path4) { if (path1.Length == 0) return Join(path2, path3, path4); if (path2.Length == 0) return Join(path1, path3, path4); if (path3.Length == 0) return Join(path1, path2, path4); if (path4.Length == 0) return Join(path1, path2, path3); return JoinInternal(path1, path2, path3, path4); } public static string Join(string? path1, string? path2) { if (string.IsNullOrEmpty(path1)) return path2 ?? string.Empty; if (string.IsNullOrEmpty(path2)) return path1; return JoinInternal(path1, path2); } public static string Join(string? path1, string? path2, string? path3) { if (string.IsNullOrEmpty(path1)) return Join(path2, path3); if (string.IsNullOrEmpty(path2)) return Join(path1, path3); if (string.IsNullOrEmpty(path3)) return Join(path1, path2); return JoinInternal(path1, path2, path3); } public static string Join(string? path1, string? path2, string? path3, string? path4) { if (string.IsNullOrEmpty(path1)) return Join(path2, path3, path4); if (string.IsNullOrEmpty(path2)) return Join(path1, path3, path4); if (string.IsNullOrEmpty(path3)) return Join(path1, path2, path4); if (string.IsNullOrEmpty(path4)) return Join(path1, path2, path3); return JoinInternal(path1, path2, path3, path4); } public static string Join(params string?[] paths) { ArgumentNullException.ThrowIfNull(paths); return Join((ReadOnlySpan<string?>)paths); } /// <summary> /// Concatenates a span of paths into a single path. /// </summary> /// <param name="paths">A span of paths.</param> /// <returns>The concatenated path.</returns> public static unsafe string Join(params ReadOnlySpan<string?> paths) { if (paths.IsEmpty) { return string.Empty; } int maxSize = 0; foreach (string? path in paths) { maxSize += path?.Length ?? 0; } maxSize += paths.Length - 1; var builder = new ValueStringBuilder(stackalloc char[260]); // MaxShortPath on Windows builder.EnsureCapacity(maxSize); for (int i = 0; i < paths.Length; i++) { string? path = paths[i]; if (string.IsNullOrEmpty(path)) { continue; } if (builder.Length == 0) { builder.Append(path); } else { if (!PathInternal.IsDirectorySeparator(builder[^1]) && !PathInternal.IsDirectorySeparator(path[0])) { builder.Append(PathInternal.DirectorySeparatorChar); } builder.Append(path); } } return builder.ToString(); } public static bool TryJoin(ReadOnlySpan<char> path1, ReadOnlySpan<char> path2, Span<char> destination, out int charsWritten) { charsWritten = 0; if (path1.Length == 0 && path2.Length == 0) return true; if (path1.Length == 0 || path2.Length == 0) { ref ReadOnlySpan<char> pathToUse = ref path1.Length == 0 ? ref path2 : ref path1; if (destination.Length < pathToUse.Length) { return false; } pathToUse.CopyTo(destination); charsWritten = pathToUse.Length; return true; } bool needsSeparator = !(EndsInDirectorySeparator(path1) || PathInternal.StartsWithDirectorySeparator(path2)); int charsNeeded = path1.Length + path2.Length + (needsSeparator ? 1 : 0); if (destination.Length < charsNeeded) return false; path1.CopyTo(destination); if (needsSeparator) destination[path1.Length] = DirectorySeparatorChar; path2.CopyTo(destination.Slice(path1.Length + (needsSeparator ? 1 : 0))); charsWritten = charsNeeded; return true; } public static bool TryJoin(ReadOnlySpan<char> path1, ReadOnlySpan<char> path2, ReadOnlySpan<char> path3, Span<char> destination, out int charsWritten) { charsWritten = 0; if (path1.Length == 0 && path2.Length == 0 && path3.Length == 0) return true; if (path1.Length == 0) return TryJoin(path2, path3, destination, out charsWritten); if (path2.Length == 0) return TryJoin(path1, path3, destination, out charsWritten); if (path3.Length == 0) return TryJoin(path1, path2, destination, out charsWritten); int neededSeparators = EndsInDirectorySeparator(path1) || PathInternal.StartsWithDirectorySeparator(path2) ? 0 : 1; bool needsSecondSeparator = !(EndsInDirectorySeparator(path2) || PathInternal.StartsWithDirectorySeparator(path3)); if (needsSecondSeparator) neededSeparators++; int charsNeeded = path1.Length + path2.Length + path3.Length + neededSeparators; if (destination.Length < charsNeeded) return false; bool result = TryJoin(path1, path2, destination, out charsWritten); Debug.Assert(result, "should never fail joining first two paths"); if (needsSecondSeparator) destination[charsWritten++] = DirectorySeparatorChar; path3.CopyTo(destination.Slice(charsWritten)); charsWritten += path3.Length; return true; } private static string CombineInternal(string first, string second) { if (first.Length == 0) return second; if (second.Length == 0) return first; if (IsPathRooted(second.AsSpan())) return second; return JoinInternal(first.AsSpan(), second.AsSpan()); } private static string CombineInternal(string first, string second, string third) { if (first.Length == 0) return CombineInternal(second, third); if (second.Length == 0) return CombineInternal(first, third); if (third.Length == 0) return CombineInternal(first, second); if (IsPathRooted(third.AsSpan())) return third; if (IsPathRooted(second.AsSpan())) return CombineInternal(second, third); return JoinInternal(first.AsSpan(), second.AsSpan(), third.AsSpan()); } private static string CombineInternal(string first, string second, string third, string fourth) { if (first.Length == 0) return CombineInternal(second, third, fourth); if (second.Length == 0) return CombineInternal(first, third, fourth); if (third.Length == 0) return CombineInternal(first, second, fourth); if (fourth.Length == 0) return CombineInternal(first, second, third); if (IsPathRooted(fourth.AsSpan())) return fourth; if (IsPathRooted(third.AsSpan())) return CombineInternal(third, fourth); if (IsPathRooted(second.AsSpan())) return CombineInternal(second, third, fourth); return JoinInternal(first.AsSpan(), second.AsSpan(), third.AsSpan(), fourth.AsSpan()); } private static string JoinInternal(ReadOnlySpan<char> first, ReadOnlySpan<char> second) { Debug.Assert(first.Length > 0 && second.Length > 0, "should have dealt with empty paths"); bool hasSeparator = PathInternal.IsDirectorySeparator(first[^1]) || PathInternal.IsDirectorySeparator(second[0]); return hasSeparator ? string.Concat(first, second) : string.Concat(first, PathInternal.DirectorySeparatorCharAsString, second); } private static string JoinInternal(ReadOnlySpan<char> first, ReadOnlySpan<char> second, ReadOnlySpan<char> third) { Debug.Assert(first.Length > 0 && second.Length > 0 && third.Length > 0, "should have dealt with empty paths"); bool firstHasSeparator = PathInternal.IsDirectorySeparator(first[^1]) || PathInternal.IsDirectorySeparator(second[0]); bool secondHasSeparator = PathInternal.IsDirectorySeparator(second[^1]) || PathInternal.IsDirectorySeparator(third[0]); return (firstHasSeparator, secondHasSeparator) switch { (false, false) => string.Concat(first, PathInternal.DirectorySeparatorCharAsString, second, PathInternal.DirectorySeparatorCharAsString, third), (false, true) => string.Concat(first, PathInternal.DirectorySeparatorCharAsString, second, third), (true, false) => string.Concat(first, second, PathInternal.DirectorySeparatorCharAsString, third), (true, true) => string.Concat(first, second, third), }; } private static unsafe string JoinInternal(ReadOnlySpan<char> first, ReadOnlySpan<char> second, ReadOnlySpan<char> third, ReadOnlySpan<char> fourth) { Debug.Assert(first.Length > 0 && second.Length > 0 && third.Length > 0 && fourth.Length > 0, "should have dealt with empty paths"); var state = new JoinInternalState { ReadOnlySpanPtr1 = (IntPtr)(&first), ReadOnlySpanPtr2 = (IntPtr)(&second), ReadOnlySpanPtr3 = (IntPtr)(&third), ReadOnlySpanPtr4 = (IntPtr)(&fourth), NeedSeparator1 = PathInternal.IsDirectorySeparator(first[^1]) || PathInternal.IsDirectorySeparator(second[0]) ? (byte)0 : (byte)1, NeedSeparator2 = PathInternal.IsDirectorySeparator(second[^1]) || PathInternal.IsDirectorySeparator(third[0]) ? (byte)0 : (byte)1, NeedSeparator3 = PathInternal.IsDirectorySeparator(third[^1]) || PathInternal.IsDirectorySeparator(fourth[0]) ? (byte)0 : (byte)1, }; return string.Create( first.Length + second.Length + third.Length + fourth.Length + state.NeedSeparator1 + state.NeedSeparator2 + state.NeedSeparator3, state, static (destination, state) => { ReadOnlySpan<char> first = *(ReadOnlySpan<char>*)state.ReadOnlySpanPtr1; first.CopyTo(destination); destination = destination.Slice(first.Length); if (state.NeedSeparator1 != 0) { destination[0] = PathInternal.DirectorySeparatorChar; destination = destination.Slice(1); } ReadOnlySpan<char> second = *(ReadOnlySpan<char>*)state.ReadOnlySpanPtr2; second.CopyTo(destination); destination = destination.Slice(second.Length); if (state.NeedSeparator2 != 0) { destination[0] = PathInternal.DirectorySeparatorChar; destination = destination.Slice(1); } ReadOnlySpan<char> third = *(ReadOnlySpan<char>*)state.ReadOnlySpanPtr3; third.CopyTo(destination); destination = destination.Slice(third.Length); if (state.NeedSeparator3 != 0) { destination[0] = PathInternal.DirectorySeparatorChar; destination = destination.Slice(1); } ReadOnlySpan<char> fourth = *(ReadOnlySpan<char>*)state.ReadOnlySpanPtr4; Debug.Assert(fourth.Length == destination.Length); fourth.CopyTo(destination); }); } private struct JoinInternalState // used to avoid rooting ValueTuple`7 { public IntPtr ReadOnlySpanPtr1, ReadOnlySpanPtr2, ReadOnlySpanPtr3, ReadOnlySpanPtr4; public byte NeedSeparator1, NeedSeparator2, NeedSeparator3; } private static ReadOnlySpan<byte> Base32Char => "abcdefghijklmnopqrstuvwxyz012345"u8; internal static unsafe void Populate83FileNameFromRandomBytes(byte* bytes, int byteCount, Span<char> chars) { // This method requires bytes of length 8 and chars of length 12. Debug.Assert(bytes != null); Debug.Assert(byteCount == 8, $"Unexpected {nameof(byteCount)}"); Debug.Assert(chars.Length == 12, $"Unexpected {nameof(chars)}.Length"); byte b0 = bytes[0]; byte b1 = bytes[1]; byte b2 = bytes[2]; byte b3 = bytes[3]; byte b4 = bytes[4]; // write to chars[11] first in order to eliminate redundant bounds checks chars[11] = (char)Base32Char[bytes[7] & 0x1F]; // Consume the 5 Least significant bits of the first 5 bytes chars[0] = (char)Base32Char[b0 & 0x1F]; chars[1] = (char)Base32Char[b1 & 0x1F]; chars[2] = (char)Base32Char[b2 & 0x1F]; chars[3] = (char)Base32Char[b3 & 0x1F]; chars[4] = (char)Base32Char[b4 & 0x1F]; // Consume 3 MSB of b0, b1, MSB bits 6, 7 of b3, b4 chars[5] = (char)Base32Char[ ((b0 & 0xE0) >> 5) | ((b3 & 0x60) >> 2)]; chars[6] = (char)Base32Char[ ((b1 & 0xE0) >> 5) | ((b4 & 0x60) >> 2)]; // Consume 3 MSB bits of b2, 1 MSB bit of b3, b4 b2 >>= 5; Debug.Assert((b2 & 0xF8) == 0, "Unexpected set bits"); if ((b3 & 0x80) != 0) b2 |= 0x08; if ((b4 & 0x80) != 0) b2 |= 0x10; chars[7] = (char)Base32Char[b2]; // Set the file extension separator chars[8] = '.'; // Consume the 5 Least significant bits of the remaining 3 bytes chars[9] = (char)Base32Char[bytes[5] & 0x1F]; chars[10] = (char)Base32Char[bytes[6] & 0x1F]; } /// <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, PathInternal.StringComparison); } private static unsafe string GetRelativePath(string relativeTo, string path, StringComparison comparisonType) { ArgumentNullException.ThrowIfNull(relativeTo); ArgumentNullException.ThrowIfNull(path); if (PathInternal.IsEffectivelyEmpty(relativeTo.AsSpan())) throw new ArgumentException(SR.Arg_PathEmpty, nameof(relativeTo)); if (PathInternal.IsEffectivelyEmpty(path.AsSpan())) throw new ArgumentException(SR.Arg_PathEmpty, nameof(path)); Debug.Assert(comparisonType == StringComparison.Ordinal || comparisonType == StringComparison.OrdinalIgnoreCase); relativeTo = GetFullPath(relativeTo); path = GetFullPath(path); // Need to check if the roots are different- if they are we need to return the "to" path. if (!PathInternal.AreRootsEqual(relativeTo, path, comparisonType)) return path; int commonLength = PathInternal.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 (EndsInDirectorySeparator(relativeTo.AsSpan())) relativeToLength--; bool pathEndsInSeparator = EndsInDirectorySeparator(path.AsSpan()); 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 var sb = new ValueStringBuilder(stackalloc char[260]); sb.EnsureCapacity(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 (PathInternal.IsDirectorySeparator(relativeTo[i])) { sb.Append(DirectorySeparatorChar); sb.Append(".."); } } } else if (PathInternal.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.AsSpan(commonLength, differenceLength)); } return sb.ToString(); } /// <summary> /// Trims one trailing directory separator beyond the root of the path. /// </summary> public static string TrimEndingDirectorySeparator(string path) => PathInternal.TrimEndingDirectorySeparator(path); /// <summary> /// Trims one trailing directory separator beyond the root of the path. /// </summary> public static ReadOnlySpan<char> TrimEndingDirectorySeparator(ReadOnlySpan<char> path) => PathInternal.TrimEndingDirectorySeparator(path); /// <summary> /// Returns true if the path ends in a directory separator. /// </summary> public static bool EndsInDirectorySeparator(ReadOnlySpan<char> path) => PathInternal.EndsInDirectorySeparator(path); /// <summary> /// Returns true if the path ends in a directory separator. /// </summary> public static bool EndsInDirectorySeparator([NotNullWhen(true)] string? path) => PathInternal.EndsInDirectorySeparator(path); } }