About • How To Use • Migration Guide • Download • Contributors • Versioning • Credits • Related • License
Pathy is a tiny source-only library that will allow you to build file and directory paths by chaining together strings like "c:", "dir1", "dir2" using
ChainablePath.New / "c:" / "dir1" / "dir2";Note how the / operator is used to chain multiple parts of a path together. This is the primary feature of Pathy. And it doesn't matter if you do that on Linux or Windows. Internally it'll use whatever path separator is suitable.
You can also use the + operator to add some phrase to the path without using a separator.
var path = ChainablePath.From("c:") / "my-path" / "to" / "a" / "directory";
path = path + "2"
// Returns "c:/my-path/to/a/directory2"
string result = path.ToString();It was heavily inspired by the best build pipeline framework available in the .NET space, Nuke. Nuke has supported these concepts for many years, but I needed this capability outside build pipelines. Lots of kudos to Matthias Koch for what I see as a brilliant idea.
It makes those chained calls to Path.Combine a thing from the past and hides the ugliness of dealing with (trailing) slashes.
It ships as a source-only package, which means you can use it in your own libraries and projects, without incurring any dependency pain on your consuming projects. It runs on .NET 4.7, .NET 8, as well as frameworks supporting .NET Standard 2.0 and 2.1.
The core Pathy package does not have any dependencies, and I purposely moved the globbing functionality into a separate package as it depends on Microsoft.Extensions.FileSystemGlobbing.
My name is Dennis Doomen and I'm a Microsoft MVP and Principal Consultant at Aviva Solutions with 28 years of experience under my belt. As a software architect and/or lead developer, I specialize in designing full-stack enterprise solutions based on .NET as well as providing coaching on all aspects of designing, building, deploying and maintaining software systems. I'm the author of several open-source projects such as Fluent Assertions, Reflectify, Liquid Projections, and I've been maintaining coding guidelines for C# since 2001.
Contact me through Email, Bluesky, Twitter/X or Mastadon
This library is available as a NuGet package on https://nuget.org. To install it, use the following command-line:
dotnet add package Pathy
It all starts with the construction of a ChainablePath instance to represent a path to a file or directory.
There are several ways of doing that.
// Various ways for constructing a ChainablePath
var path = ChainablePath.From("c:") / "my-path" / "to" / "a" / "directory";
var path = ChainablePath.New / "c:" / "my-path" / "to" / "a" / "directory";
var path = "c:/mypath/to/a/directory".ToPath();
var path = (ChainablePath)"c:/mypath/to/a/directory";
// Find the first available file in the order of appearance and return a
// ChainablePath representing that file
var path = ChainablePath.FindFirst("build.yml", ".github\\build.yml");Additionally, you can use ChainablePath.Current to get the current working directory as an instance of ChainablePath, and ChainablePath.Temp to get that for the user's temporary folder.
To convert an instance of ChainablePath back to a string, you can either call ToString() or cast the instance to a string.
string rawPath = path.ToString();
string rawPath = (string)path;ChainablePath also implements IFormattable, and, on .NET 6.0 and later, ISpanFormattable, so it can be used directly inside interpolated strings and composite format strings without an intermediate allocation:
Console.WriteLine($"Deploying from {path}"); // no extra string allocation on .NET 6+
string message = string.Format("Path: {0}", path);ChainablePath has no format specifiers of its own, so any format string you pass (e.g. path.ToString("X")) is ignored and the plain path is returned.
Know that ChainablePath overrides Equals and GetHashCode, so you can always compare two instances as you're used to.
Given an instance of ChainablePath, you can get a lot of useful information:
Namereturns the full name, but without the directory, whereasExtensiongives you the extension including the dot.Directory,ParentorDirectoryNamegive you the (parent) directory of a file or directory.- The range operator
..in newer versions of .NET serves a similar purpose, e.g.path / .. / "file.txt" - To see if a path is absolute, use
IsRooted - Not sure if a path points to an actual file system entry? Use
IsFile,IsDirectoryorExists - Want to know the delta between two paths? Use
AsRelativeTo. - To determine if a file has a case-insensitive extension, use
HasExtension(".txt")orHasExtension("txt"). - To check if a path has a specific file or directory name (case-insensitive), use
HasName("MyFile.txt"). - Get the last write time in UTC using
LastWriteTimeUtcfor both files and directories. - To do a best-effort check for characters that are invalid on the current platform, use
IsValid(see Edge cases below).
And if the built-in functionality really isn't enough, you can always call ToDirectoryInfo or ToFileInfo to continue with an instance of DirectoryInfo and FileInfo.
Other features
- Build an absolute path from a relative path using
ToAbsoluteto use the current directory as the base orToAbsolute(parentPath)to use something else as the base. - Finding the closest parent directory containing a file matching one or more wildcards. For example, given you have a
ChainablePathpointing to a.csprojfile, you can then useFindParentWithFileMatching("*.sln", "*.slnx")to find the directory containing the.slnor.slnxfile.
If you have a ChainablePath that could represent either a file or a directory, and you want to resolve a specific file name, you can use the ResolveFile extension method:
// When the path is a directory containing the file
var directory = ChainablePath.From("c:/projects/myapp");
var configFile = directory.ResolveFile("appsettings.json");
// Returns: c:/projects/myapp/appsettings.json (if it exists)
// When the path is already the file itself
var filePath = ChainablePath.From("c:/projects/myapp/appsettings.json");
var resolved = filePath.ResolveFile("appsettings.json");
// Returns: c:/projects/myapp/appsettings.json (if it exists)
// When the file doesn't exist
var missing = directory.ResolveFile("missing.txt");
// Returns: ChainablePath.EmptyThe method performs case-insensitive file name matching, so ResolveFile("CONFIG.JSON") will match config.json.
ChainablePath has a TypeConverter (ChainablePathTypeConverter) applied to it out of the box, so it works transparently with anything that relies on System.ComponentModel.TypeConverter to convert to and from a string, such as binding appsettings.json configuration to a class, MSBuild properties, or command-line argument parsers. No extra setup is required.
public class MyOptions
{
public ChainablePath WorkingDirectory { get; set; }
}System.Text.Json support is opt-in rather than automatic, because it isn't available out of the box on every framework this source-only package targets (notably .NET Framework 4.7). To enable it, define the PATHY_SYSTEM_TEXT_JSON compilation symbol in your own project (and reference the System.Text.Json package if your target framework doesn't already ship it). This activates the [JsonConverter(typeof(ChainablePathJsonConverter))] attribute on ChainablePath, so it serializes and deserializes as its plain string representation:
<PropertyGroup>
<DefineConstants>$(DefineConstants);PATHY_SYSTEM_TEXT_JSON</DefineConstants>
</PropertyGroup>Serialized paths are just platform-specific strings, so round-tripping a Windows-style path on Linux (or vice versa) is up to you; the converter performs no path translation.
Pathy does not validate or normalize a path's content beyond combining segments and resolving . / .. traversals. This has some consequences worth knowing about:
- Invalid characters.
ChainablePath.Fromand the/and+operators do not check whether a segment contains characters that are invalid on the current platform (e.g.<,>,|,?,*or control characters on Windows). Constructing such a path never throws; the invalid characters are simply carried through, and any failure will happen later, when the path is actually used to access the file system (e.g. viaFile.Exists,CreateDirectoryRecursively, etc.). If you want to check upfront, useIsValid:var candidate = downloadDirectory / userSuppliedName; if (!candidate.IsValid) { return BadRequest("That file name cannot be used on this system."); }
IsValidis a best-effort check based onPath.GetInvalidPathChars()andPath.GetInvalidFileNameChars()for the current platform. It does not check things like reserved device names (CON,NUL, ...), trailing dots/spaces, or file system-specific length limits, so a path can still fail to be created even whenIsValidreturnstrue, and (rarely) the reverse. - Long paths. Pathy passes paths through unchanged, including ones longer than the traditional Windows
MAX_PATH(260 characters). Whether such a path works depends entirely on the host: modern .NET (Core/5+) and Windows with long path support enabled handle it transparently, while older configurations may not. If you prepend the\\?\extended-length prefix yourself (e.g.ChainablePath.From(@"\\?\C:\very\long\path")), Pathy keeps that prefix intact while you chain additional segments onto it with/. - UNC paths. A path like
\\server\share\folderis treated as rooted (IsRootedreturnstrue), andRootreturns the server-and-share part (\\server\share), not just the server. Walking up the hierarchy withParent(orDirectory) stops at that share root and then returnsChainablePath.Empty, exactly like walking up from a drive-letter root (e.g.C:\) does — it does not throw and it does not walk further up to the bare server name.AsRelativeToworks the same way as for any other rooted path, as long as both paths share the same root.
If you add the Pathy.Globbing NuGet source-only package as well, you'll get access to the GlobFiles method. With that, you can fetch a collection of files like this:
// Match files with a single pattern
ChainablePath[] files = (ChainablePath.Current / "Artifacts").GlobFiles("**/*.json");
// Match files with multiple patterns
ChainablePath[] files = (ChainablePath.Current / "Artifacts").GlobFiles("**/*.txt", "**/*.md", "**/*.json");The same package also provides Matches, which tests whether a path matches a glob pattern without touching
the file system at all - the path doesn't need to exist and no directory is enumerated:
changedFile.Matches("**/*.cs"); // true for src/Pathy/ChainablePath.cs
changedFile.Matches("**/bin/**", "**/obj/**"); // filter out build output
var relevant = changedFiles.Where(x => x.Matches("src/**/*.cs")).ToArray();Next to that, Pathy also provides a bunch of extension methods to operate on the file-system:
CreateDirectoryRecursivelyDeleteFileOrDirectoryMoveFileOrDirectory
These methods also support operating on collections of ChainablePath objects:
// Delete multiple files or directories at once
var files = new[] {
ChainablePath.Temp / "file1.txt",
ChainablePath.Temp / "file2.txt",
ChainablePath.Temp / "dir1"
};
files.DeleteFileOrDirectory();
// Move multiple files to a destination directory
var filesToMove = (ChainablePath.Current / "source").GlobFiles("*.txt");
filesToMove.MoveFileOrDirectory(ChainablePath.Current / "destination");This section helps you translate code that uses Path.Combine/FileInfo/DirectoryInfo, or Nuke's AbsolutePath, into the equivalent ChainablePath code.
| What you did before | What you do with Pathy |
|---|---|
Path.Combine("c:", "dir1", "dir2") |
ChainablePath.From("c:") / "dir1" / "dir2" or ChainablePath.New / "c:" / "dir1" / "dir2" |
Path.Combine(path, "sub") + "2" |
path / "sub" + "2" |
Directory.GetCurrentDirectory() |
ChainablePath.Current |
Path.GetTempPath() |
ChainablePath.Temp |
Path.GetFileName(path) |
path.Name |
Path.GetExtension(path) |
path.Extension |
Path.GetDirectoryName(path) |
path.Directory or path.DirectoryName (string) |
Path.IsPathRooted(path) |
path.IsRooted |
Path.GetPathRoot(path) |
path.Root |
Path.GetFullPath(path) |
path.ToAbsolute() or path.ToAbsolute(parentPath) |
File.Exists(path) |
path.FileExists or path.IsFile |
Directory.Exists(path) |
path.DirectoryExists or path.IsDirectory |
File.Exists(path) || Directory.Exists(path) |
path.Exists |
Directory.CreateDirectory(path) |
path.CreateDirectoryRecursively() |
File.Delete(path) / Directory.Delete(path, true) |
path.DeleteFileOrDirectory() |
File.Move(source, dest) / Directory.Move(source, dest) |
sourcePath.MoveFileOrDirectory(destinationDirectory) |
File.GetLastWriteTimeUtc(path) / Directory.GetLastWriteTimeUtc(path) |
path.LastWriteTimeUtc |
new FileInfo(path) |
path.ToFileInfo() |
new DirectoryInfo(path) |
path.ToDirectoryInfo() |
path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) |
path.HasExtension(".txt") or path.HasExtension("txt") |
string.Equals(Path.GetFileName(path), "MyFile.txt", StringComparison.OrdinalIgnoreCase) |
path.HasName("MyFile.txt") |
| Manually walking up parent directories to find a file | path.FindParentWithFileMatching("*.sln", "*.slnx") |
| Manually computing a relative path between two paths | path.AsRelativeTo(basePath) |
| Manually checking whether a directory contains a specific file, case-insensitively | directory.ResolveFile("appsettings.json") |
A ChainablePath can be implicitly cast to and from a string, so you can pass it directly to any API that still expects a plain string:
string rawPath = path;
ChainablePath path = "c:/mypath/to/a/directory";Pathy was heavily inspired by Nuke's AbsolutePath, so most of the syntax will already feel familiar. The main differences are the entry points and a couple of naming choices.
| What you did with Nuke | What you do with Pathy |
|---|---|
(AbsolutePath)"c:/dir1/dir2" |
(ChainablePath)"c:/dir1/dir2" or "c:/dir1/dir2".ToPath() |
NukeBuild.RootDirectory / "dir1" / "dir2" |
ChainablePath.From("c:") / "dir1" / "dir2" |
EnvironmentInfo.WorkingDirectory |
ChainablePath.Current |
path.Parent |
path.Parent or path / .. |
path / ".." / ".." |
path / .. / .. |
path.Name / path.NameWithoutExtension |
path.Name / Path.GetFileNameWithoutExtension(path.Name) |
path.Extension |
path.Extension |
path.FileExists() |
path.FileExists or path.IsFile (property, not method) |
path.DirectoryExists() |
path.DirectoryExists or path.IsDirectory (property, not method) |
path.GlobFiles("**/*.json") |
path.GlobFiles("**/*.json") (requires the Pathy.Globbing package) |
path.CreateOrCleanDirectory() |
path.CreateDirectoryRecursively() followed by path.DeleteFileOrDirectory() if you need to clean it first |
path.DeleteFile() / path.DeleteDirectory() |
path.DeleteFileOrDirectory() |
path.MoveToDirectory(destination) |
path.MoveFileOrDirectory(destination) |
path.GetRelativePathTo(basePath) |
path.AsRelativeTo(basePath) |
Unlike Nuke's AbsolutePath, Pathy's ChainablePath is not restricted to absolute paths — it can represent both relative and absolute paths, and ships without any dependency on the Nuke build system, so you can use it in application and library code, not just build scripts.
To build this repository locally so you can contribute to it, you need the following:
- The .NET SDKs for .NET 4.7, 8.0.
- Visual Studio, JetBrains Rider or Visual Studio Code with the C# DevKit
You can also build, run the unit tests and package the code using the following command-line:
build.ps1
Or, if you have, the Nuke tool installed:
nuke
Also try using --help to see all the available options or --plan to see what the scripts does.
Your contributions are always welcome! Please have a look at the contribution guidelines first.
Previous contributors include:
(Made with contrib.rocks)
This library uses Semantic Versioning to give meaning to the version numbers. For the versions available, see the tags on this repository.
This library wouldn't have been possible without the following tools, packages and companies:
- Nuke - Smart automation for DevOps teams and CI/CD pipelines by Matthias Koch
- xUnit - Community-focused unit testing tool for .NET by Brad Wilson
- Coverlet - Cross platform code coverage for .NET by Toni Solarin-Sodara
- GitVersion - From git log to SemVer in no time
- ReportGenerator - Converts coverage reports by Daniel Palme
- StyleCopyAnalyzer - StyleCop rules for .NET
- Roslynator - A set of code analysis tools for C# by Josef Pihrt
- CSharpCodingGuidelines - Roslyn analyzers by Bart Koelman to go with the C# Coding Guidelines
- Meziantou - Another set of awesome Roslyn analyzers by Gérald Barré
- Verify - Snapshot testing by Simon Cropp
- My Blog
- PackageGuard - Get a grip on your open-source packages
- Reflectify - Reflection extensions without causing dependency pains
- .NET Library Package Templates - "dotnet new" templates for building NuGet-published multi-targeting libraries with all the bells and whistles
- FluentAssertions - Extension methods to fluently assert the outcome of .NET tests
- C# Coding Guidelines - Forkable coding guidelines for all C# versions
This project is licensed under the MIT License - see the LICENSE file for details.
