Last active
March 16, 2024 03:54
-
-
Save attentive/7ecd75941a85255a9d08ff4e4eb7bdc2 to your computer and use it in GitHub Desktop.
Path.Combine doesn't work correctly. Here's an alternative function that works a bit better and accepts variadic arguments.
This file contains 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
public static class Paths | |
{ | |
/// <summary> | |
/// Combine up to 1,000 filesystem paths in a way that you'd hope works better than Path.Combine, which it turns out | |
/// doesn't work properly. | |
/// </summary> | |
/// <param name="paths">A non-null, non-empty array of non-empty, non-whitespace input paths to join together (properly)</param> | |
/// <returns>A combined path</returns> | |
public static string Combine(params string[] paths) | |
{ | |
if (paths is null) | |
{ | |
throw new ArgumentNullException(nameof(paths), "A null collection cannot be passed as input"); | |
} | |
if (paths.Any(string.IsNullOrWhiteSpace)) | |
{ | |
throw new ArgumentException("Specified paths cannot be empty or whitespace", nameof(paths)); | |
} | |
if (paths.Length == 0) | |
{ | |
throw new ArgumentException("At least one path to combine must be specified", nameof(paths)); | |
} | |
if (paths.Length > 1000) | |
{ | |
throw new ArgumentException( | |
$"Specified number of paths to combine is {paths.Length} but the maximum permitted is 1,000", | |
nameof(paths)); | |
} | |
// Fix any paths that are "rooted" but not fully qualified eg on Windows "\foo.txt", "C:foo.txt" | |
// On Unix-alike platforms this is a no-op as IsPathRooted and IsPathFullyQualified are equivalent | |
static string FullyQualifyRootedPath(string path) | |
{ | |
return Path.IsPathRooted(path) ? Path.GetFullPath(path) : path; | |
} | |
if (paths.Length > 1) | |
{ | |
string combined = Combine(paths[1..]); | |
// If the rest of the paths combined is already rooted, fully qualify it and return | |
// (similar behaviour to Path.Combine) | |
if (Path.IsPathRooted(combined)) | |
{ | |
return FullyQualifyRootedPath(combined); | |
} | |
return string.Join( | |
Path.DirectorySeparatorChar, | |
[ | |
FullyQualifyRootedPath(paths[0].Trim().TrimEnd(Path.DirectorySeparatorChar)), | |
combined.TrimStart(Path.DirectorySeparatorChar) | |
] | |
); | |
} | |
return FullyQualifyRootedPath(paths[0].Trim()); | |
} | |
} |
This file contains 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
public class CombineTests | |
{ | |
[Fact] | |
public void TestCombine() | |
{ | |
// Two relative paths | |
Assert.Equal(@"path1\path2", Paths.Combine(@"path1", "path2")); | |
Assert.Equal(@"path1\path2", Paths.Combine(@"path1\", "path2")); | |
Assert.Equal(@"path1\path2\", Paths.Combine(@"path1\", @"path2\")); | |
// Three relative paths | |
Assert.Equal(@"path1\path2\path3", Paths.Combine(@"path1", "path2", "path3")); | |
Assert.Equal(@"path1\path2\path3", Paths.Combine(@"path1\", @"path2\", "path3")); | |
Assert.Equal(@"path1\path2\path3\", Paths.Combine(@"path1\", "path2", @"path3\")); | |
// Absolute combined with one or two relatives | |
Assert.Equal(@"C:\path1\path2\path3", Paths.Combine(@"C:\path1", "path2", "path3")); | |
Assert.Equal(@"C:\path1\path2\", Paths.Combine(@"C:\path1\", @"path2\")); | |
Assert.Equal(@"C:\path1\path2\path3", Paths.Combine(@"\path1", "path2", "path3")); | |
Assert.Equal(@"C:\path1\path2\", Paths.Combine(@"\path1\", @"path2\")); | |
// Absolute combined with a mix of rooted, absolute and relative | |
Assert.Equal(@"D:\path2\path3", Paths.Combine(@"C:\path1", @"D:\path2", "path3")); | |
Assert.Equal(@"E:\path2\", Paths.Combine(@"C:\path1\", @"E:\path2\")); | |
Assert.Equal(@"C:\path2\path3", Paths.Combine(@"\path1", @"\path2", "path3")); | |
Assert.Equal(@"C:\path2\", Paths.Combine(@"\path1\", @"\path2\")); | |
// Confirm exception is thrown when zero paths are used as input | |
Assert.Throws<ArgumentException>(() => Paths.Combine()); | |
// Confirm exception is thrown when too many paths are used as input | |
string[] lotsOfPaths = Enumerable.Range(0, 1001).Select(i => $"path{i}").ToArray(); | |
Assert.True(lotsOfPaths.Length > 1000); | |
Assert.Throws<ArgumentException>(() => Paths.Combine(lotsOfPaths)); | |
// Confirm exceptions are thrown if any whitespace or empty paths are in the input | |
Assert.Throws<ArgumentException>(() => Paths.Combine(@"\path1", " ", "path2")); | |
Assert.Throws<ArgumentException>(() => Paths.Combine(@"\path1", "\t", "path2")); | |
Assert.Throws<ArgumentException>(() => Paths.Combine(@"\path1", string.Empty, "path2")); | |
// Check we handle a null array input correctly | |
string[]? paths = null; | |
Assert.Throws<ArgumentNullException>(() => Paths.Combine(paths!)); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
A neater implementation with more guards on invalid input, making use of C# 8 local static functions and correctly handling the vagaries of IsPathRooted and IsPathFullyQualified on Windows (see more here).
Added a unit test.