From b4ba5b9487458c174eb33eee4e19f71a8af3e6d1 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Wed, 26 Nov 2025 15:21:21 +0100 Subject: [PATCH 1/3] Test --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1f55773..49022cc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@

+ +
Pathy
From a04d946629d46a44d1fdbe21fd7990fd64d75a19 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Mon, 10 Aug 2026 16:47:06 +0200 Subject: [PATCH 2/3] Implement ISpanFormattable/IFormattable on ChainablePath (issue #148) Adds interpolated-string-handler support so paths written into DefaultInterpolatedStringHandler (e.g. $"{path}") avoid boxing and an intermediate string allocation. - IFormattable.ToString(format, provider) is implemented unconditionally on all target frameworks, since a ChainablePath has no format specifiers of its own and just returns the underlying path. - ISpanFormattable.TryFormat is implemented only under NET6_0_OR_GREATER, since that interface does not exist on netstandard2.0/2.1 or net47. - Added unit tests covering interpolated-string usage, ToString(format, provider), and TryFormat success/destination-too-small cases. - Updated approved public API snapshots for all four target frameworks. Fixes #148 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ApprovedApi/pathy.net47.verified.txt | 3 +- .../ApprovedApi/pathy.net8.0.verified.txt | 4 +- .../pathy.netstandard2.0.verified.txt | 3 +- .../pathy.netstandard2.1.verified.txt | 3 +- Pathy.Specs/ChainablePathSpecs.cs | 60 +++++++++++++++++++ Pathy/ChainablePath.cs | 49 ++++++++++++++- 6 files changed, 116 insertions(+), 6 deletions(-) diff --git a/Pathy.ApiVerificationTests/ApprovedApi/pathy.net47.verified.txt b/Pathy.ApiVerificationTests/ApprovedApi/pathy.net47.verified.txt index 77cabc3..f0e75ef 100644 --- a/Pathy.ApiVerificationTests/ApprovedApi/pathy.net47.verified.txt +++ b/Pathy.ApiVerificationTests/ApprovedApi/pathy.net47.verified.txt @@ -1,7 +1,7 @@ [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Pathy.Specs")] namespace Pathy { - public readonly struct ChainablePath : System.IEquatable + public readonly struct ChainablePath : System.IEquatable, System.IFormattable { public Pathy.ChainablePath Directory { get; } public bool DirectoryExists { get; } @@ -30,6 +30,7 @@ namespace Pathy public System.IO.DirectoryInfo ToDirectoryInfo() { } public System.IO.FileInfo ToFileInfo() { } public override string ToString() { } + public string ToString(string? format, System.IFormatProvider? formatProvider) { } public static Pathy.ChainablePath FindFirst(params Pathy.ChainablePath[] paths) { } public static Pathy.ChainablePath FindFirst(params string[] paths) { } public static Pathy.ChainablePath From(string path) { } diff --git a/Pathy.ApiVerificationTests/ApprovedApi/pathy.net8.0.verified.txt b/Pathy.ApiVerificationTests/ApprovedApi/pathy.net8.0.verified.txt index b54cce5..8d1e286 100644 --- a/Pathy.ApiVerificationTests/ApprovedApi/pathy.net8.0.verified.txt +++ b/Pathy.ApiVerificationTests/ApprovedApi/pathy.net8.0.verified.txt @@ -1,7 +1,7 @@ [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Pathy.Specs")] namespace Pathy { - public readonly struct ChainablePath : System.IEquatable + public readonly struct ChainablePath : System.IEquatable, System.IFormattable, System.ISpanFormattable { public Pathy.ChainablePath Directory { get; } public bool DirectoryExists { get; } @@ -31,6 +31,8 @@ namespace Pathy public System.IO.DirectoryInfo ToDirectoryInfo() { } public System.IO.FileInfo ToFileInfo() { } public override string ToString() { } + public string ToString(string? format, System.IFormatProvider? formatProvider) { } + public bool TryFormat(System.Span destination, out int charsWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) { } public static Pathy.ChainablePath FindFirst(params Pathy.ChainablePath[] paths) { } public static Pathy.ChainablePath FindFirst(params string[] paths) { } public static Pathy.ChainablePath From(string path) { } diff --git a/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.0.verified.txt b/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.0.verified.txt index 77cabc3..f0e75ef 100644 --- a/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.0.verified.txt +++ b/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.0.verified.txt @@ -1,7 +1,7 @@ [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Pathy.Specs")] namespace Pathy { - public readonly struct ChainablePath : System.IEquatable + public readonly struct ChainablePath : System.IEquatable, System.IFormattable { public Pathy.ChainablePath Directory { get; } public bool DirectoryExists { get; } @@ -30,6 +30,7 @@ namespace Pathy public System.IO.DirectoryInfo ToDirectoryInfo() { } public System.IO.FileInfo ToFileInfo() { } public override string ToString() { } + public string ToString(string? format, System.IFormatProvider? formatProvider) { } public static Pathy.ChainablePath FindFirst(params Pathy.ChainablePath[] paths) { } public static Pathy.ChainablePath FindFirst(params string[] paths) { } public static Pathy.ChainablePath From(string path) { } diff --git a/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.1.verified.txt b/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.1.verified.txt index b54cce5..0290788 100644 --- a/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.1.verified.txt +++ b/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.1.verified.txt @@ -1,7 +1,7 @@ [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Pathy.Specs")] namespace Pathy { - public readonly struct ChainablePath : System.IEquatable + public readonly struct ChainablePath : System.IEquatable, System.IFormattable { public Pathy.ChainablePath Directory { get; } public bool DirectoryExists { get; } @@ -31,6 +31,7 @@ namespace Pathy public System.IO.DirectoryInfo ToDirectoryInfo() { } public System.IO.FileInfo ToFileInfo() { } public override string ToString() { } + public string ToString(string? format, System.IFormatProvider? formatProvider) { } public static Pathy.ChainablePath FindFirst(params Pathy.ChainablePath[] paths) { } public static Pathy.ChainablePath FindFirst(params string[] paths) { } public static Pathy.ChainablePath From(string path) { } diff --git a/Pathy.Specs/ChainablePathSpecs.cs b/Pathy.Specs/ChainablePathSpecs.cs index 3122135..69126b2 100644 --- a/Pathy.Specs/ChainablePathSpecs.cs +++ b/Pathy.Specs/ChainablePathSpecs.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.IO; using System.Reflection; using FluentAssertions; @@ -60,6 +61,65 @@ public void Can_build_a_path_from_a_drive_letter(string drive) path.IsRooted.Should().BeTrue(); } + [Fact] + public void Can_use_a_path_inside_an_interpolated_string() + { + // Arrange + var path = ChainablePath.From(@"C:\some\file.txt"); + + // Act + string result = $"The path is {path}"; + + // Assert + result.Should().Be(@"The path is C:\some\file.txt"); + } + + [Fact] + public void Can_format_a_path_using_ToString_with_format_and_provider() + { + // Arrange + var path = ChainablePath.From(@"C:\some\file.txt"); + + // Act + string result = path.ToString(format: "whatever", CultureInfo.InvariantCulture); + + // Assert + result.Should().Be(@"C:\some\file.txt"); + } + +#if NET6_0_OR_GREATER + [Fact] + public void Can_try_format_a_path_into_a_span_that_is_large_enough() + { + // Arrange + var path = ChainablePath.From(@"C:\some\file.txt"); + Span destination = stackalloc char[path.ToString().Length]; + + // Act + bool success = path.TryFormat(destination, out int charsWritten, default, null); + + // Assert + success.Should().BeTrue(); + charsWritten.Should().Be(path.ToString().Length); + destination.ToString().Should().Be(path.ToString()); + } + + [Fact] + public void Cannot_try_format_a_path_into_a_span_that_is_too_small() + { + // Arrange + var path = ChainablePath.From(@"C:\some\file.txt"); + Span destination = stackalloc char[2]; + + // Act + bool success = path.TryFormat(destination, out int charsWritten, default, null); + + // Assert + success.Should().BeFalse(); + charsWritten.Should().Be(0); + } +#endif + [Theory] [InlineData("")] [InlineData(null)] diff --git a/Pathy/ChainablePath.cs b/Pathy/ChainablePath.cs index 57d475d..321c7eb 100644 --- a/Pathy/ChainablePath.cs +++ b/Pathy/ChainablePath.cs @@ -15,12 +15,22 @@ namespace Pathy /// Represents an absolute or relative path to a directory or file that simplifies operations like combining paths using /// the / operator /// +#if NET6_0_OR_GREATER #if PATHY_PUBLIC - public readonly record struct ChainablePath + public readonly record struct ChainablePath : IFormattable, ISpanFormattable #else [global::Microsoft.CodeAnalysis.Embedded] [global::System.Diagnostics.DebuggerNonUserCode] - internal readonly record struct ChainablePath + internal readonly record struct ChainablePath : IFormattable, ISpanFormattable +#endif +#else +#if PATHY_PUBLIC + public readonly record struct ChainablePath : IFormattable +#else + [global::Microsoft.CodeAnalysis.Embedded] + [global::System.Diagnostics.DebuggerNonUserCode] + internal readonly record struct ChainablePath : IFormattable +#endif #endif { private readonly string path = string.Empty; @@ -433,6 +443,41 @@ public override string ToString() return path; } + /// + /// Returns the string representation of the current instance. + /// + /// + /// A has no format specifiers of its own, so and + /// are ignored and the underlying path is returned as-is. + /// + public string ToString(string? format, IFormatProvider? formatProvider) + { + return path; + } + +#if NET6_0_OR_GREATER + /// + /// Tries to write the string representation of the current instance into the + /// provided span of characters, avoiding the intermediate string allocation that would otherwise be needed, + /// for instance when the path is used inside an interpolated string. + /// + /// + /// A has no format specifiers of its own, so and + /// are ignored. + /// + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + if (!path.AsSpan().TryCopyTo(destination)) + { + charsWritten = 0; + return false; + } + + charsWritten = path.Length; + return true; + } +#endif + /// /// Allows casting a instance to a string so it can be used anywhere where a path as string is expected. /// From 9fdf75b24725f363bdaa74d31f72483fbae9b9ed Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Mon, 10 Aug 2026 18:53:36 +0200 Subject: [PATCH 3/3] Fix build errors after merge and document formatting in README --- .../ApprovedApi/pathy.net47.verified.txt | 3 ++- .../ApprovedApi/pathy.net8.0.verified.txt | 4 +++- .../ApprovedApi/pathy.netstandard2.0.verified.txt | 3 ++- .../ApprovedApi/pathy.netstandard2.1.verified.txt | 3 ++- Pathy.Specs/ChainablePathSpecs.cs | 4 ++-- README.md | 9 +++++++++ 6 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Pathy.ApiVerificationTests/ApprovedApi/pathy.net47.verified.txt b/Pathy.ApiVerificationTests/ApprovedApi/pathy.net47.verified.txt index 40dd1e5..68c4281 100644 --- a/Pathy.ApiVerificationTests/ApprovedApi/pathy.net47.verified.txt +++ b/Pathy.ApiVerificationTests/ApprovedApi/pathy.net47.verified.txt @@ -3,7 +3,7 @@ namespace Pathy { [System.ComponentModel.TypeConverter(typeof(Pathy.ChainablePathTypeConverter))] [System.Text.Json.Serialization.JsonConverter(typeof(Pathy.ChainablePathJsonConverter))] - public readonly struct ChainablePath : System.IEquatable + public readonly struct ChainablePath : System.IEquatable, System.IFormattable { public Pathy.ChainablePath Directory { get; } public bool DirectoryExists { get; } @@ -32,6 +32,7 @@ namespace Pathy public System.IO.DirectoryInfo ToDirectoryInfo() { } public System.IO.FileInfo ToFileInfo() { } public override string ToString() { } + public string ToString(string? format, System.IFormatProvider? formatProvider) { } public System.Uri ToUri() { } public static Pathy.ChainablePath FindFirst(params Pathy.ChainablePath[] paths) { } public static Pathy.ChainablePath FindFirst(params string[] paths) { } diff --git a/Pathy.ApiVerificationTests/ApprovedApi/pathy.net8.0.verified.txt b/Pathy.ApiVerificationTests/ApprovedApi/pathy.net8.0.verified.txt index fabfe80..60849b9 100644 --- a/Pathy.ApiVerificationTests/ApprovedApi/pathy.net8.0.verified.txt +++ b/Pathy.ApiVerificationTests/ApprovedApi/pathy.net8.0.verified.txt @@ -3,7 +3,7 @@ namespace Pathy { [System.ComponentModel.TypeConverter(typeof(Pathy.ChainablePathTypeConverter))] [System.Text.Json.Serialization.JsonConverter(typeof(Pathy.ChainablePathJsonConverter))] - public readonly struct ChainablePath : System.IEquatable + public readonly struct ChainablePath : System.IEquatable, System.IFormattable, System.ISpanFormattable { public Pathy.ChainablePath Directory { get; } public bool DirectoryExists { get; } @@ -33,7 +33,9 @@ namespace Pathy public System.IO.DirectoryInfo ToDirectoryInfo() { } public System.IO.FileInfo ToFileInfo() { } public override string ToString() { } + public string ToString(string? format, System.IFormatProvider? formatProvider) { } public System.Uri ToUri() { } + public bool TryFormat(System.Span destination, out int charsWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) { } public static Pathy.ChainablePath FindFirst(params Pathy.ChainablePath[] paths) { } public static Pathy.ChainablePath FindFirst(params string[] paths) { } public static Pathy.ChainablePath From(string path) { } diff --git a/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.0.verified.txt b/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.0.verified.txt index 40dd1e5..68c4281 100644 --- a/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.0.verified.txt +++ b/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.0.verified.txt @@ -3,7 +3,7 @@ namespace Pathy { [System.ComponentModel.TypeConverter(typeof(Pathy.ChainablePathTypeConverter))] [System.Text.Json.Serialization.JsonConverter(typeof(Pathy.ChainablePathJsonConverter))] - public readonly struct ChainablePath : System.IEquatable + public readonly struct ChainablePath : System.IEquatable, System.IFormattable { public Pathy.ChainablePath Directory { get; } public bool DirectoryExists { get; } @@ -32,6 +32,7 @@ namespace Pathy public System.IO.DirectoryInfo ToDirectoryInfo() { } public System.IO.FileInfo ToFileInfo() { } public override string ToString() { } + public string ToString(string? format, System.IFormatProvider? formatProvider) { } public System.Uri ToUri() { } public static Pathy.ChainablePath FindFirst(params Pathy.ChainablePath[] paths) { } public static Pathy.ChainablePath FindFirst(params string[] paths) { } diff --git a/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.1.verified.txt b/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.1.verified.txt index fabfe80..1b995f0 100644 --- a/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.1.verified.txt +++ b/Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.1.verified.txt @@ -3,7 +3,7 @@ namespace Pathy { [System.ComponentModel.TypeConverter(typeof(Pathy.ChainablePathTypeConverter))] [System.Text.Json.Serialization.JsonConverter(typeof(Pathy.ChainablePathJsonConverter))] - public readonly struct ChainablePath : System.IEquatable + public readonly struct ChainablePath : System.IEquatable, System.IFormattable { public Pathy.ChainablePath Directory { get; } public bool DirectoryExists { get; } @@ -33,6 +33,7 @@ namespace Pathy public System.IO.DirectoryInfo ToDirectoryInfo() { } public System.IO.FileInfo ToFileInfo() { } public override string ToString() { } + public string ToString(string? format, System.IFormatProvider? formatProvider) { } public System.Uri ToUri() { } public static Pathy.ChainablePath FindFirst(params Pathy.ChainablePath[] paths) { } public static Pathy.ChainablePath FindFirst(params string[] paths) { } diff --git a/Pathy.Specs/ChainablePathSpecs.cs b/Pathy.Specs/ChainablePathSpecs.cs index 4b68c29..ef52d2f 100644 --- a/Pathy.Specs/ChainablePathSpecs.cs +++ b/Pathy.Specs/ChainablePathSpecs.cs @@ -81,7 +81,7 @@ public void Can_format_a_path_using_ToString_with_format_and_provider() var path = ChainablePath.From(@"C:\some\file.txt"); // Act - string result = path.ToString(format: "whatever", CultureInfo.InvariantCulture); + string result = path.ToString("whatever", CultureInfo.InvariantCulture); // Assert result.Should().Be(@"C:\some\file.txt"); @@ -741,7 +741,7 @@ public void Matches_with_multiple_patterns_throws_when_no_patterns_provided() var path = ChainablePath.Temp / "file.cs"; // Act & Assert - var act = () => path.Matches(new string[0]); + var act = () => path.Matches(Array.Empty()); act.Should().Throw() .WithMessage("*At least one glob pattern must be provided*") diff --git a/README.md b/README.md index a923da6..d50e78e 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,15 @@ 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: + +```csharp +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. + ### Working with paths Know that `ChainablePath` overrides `Equals` and `GetHashCode`, so you can always compare two instances as you're used to.