Skip to content

Instantly share code, notes, and snippets.

@attentive
Last active March 16, 2024 03:54
Show Gist options
  • Save attentive/7ecd75941a85255a9d08ff4e4eb7bdc2 to your computer and use it in GitHub Desktop.
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.
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());
}
}
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!));
}
}
@attentive
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment