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 2c19e3e..ef52d2f 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("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)] @@ -681,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/Pathy/ChainablePath.cs b/Pathy/ChainablePath.cs index 6b64f55..785fa68 100644 --- a/Pathy/ChainablePath.cs +++ b/Pathy/ChainablePath.cs @@ -19,12 +19,13 @@ 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 [TypeConverter(typeof(ChainablePathTypeConverter))] #if PATHY_SYSTEM_TEXT_JSON [JsonConverter(typeof(ChainablePathJsonConverter))] #endif - public readonly record struct ChainablePath + public readonly record struct ChainablePath : IFormattable, ISpanFormattable #else [global::Microsoft.CodeAnalysis.Embedded] [global::System.Diagnostics.DebuggerNonUserCode] @@ -32,7 +33,24 @@ public readonly record struct ChainablePath #if PATHY_SYSTEM_TEXT_JSON [JsonConverter(typeof(ChainablePathJsonConverter))] #endif - internal readonly record struct ChainablePath + internal readonly record struct ChainablePath : IFormattable, ISpanFormattable +#endif +#else +#if PATHY_PUBLIC + [TypeConverter(typeof(ChainablePathTypeConverter))] +#if PATHY_SYSTEM_TEXT_JSON + [JsonConverter(typeof(ChainablePathJsonConverter))] +#endif + public readonly record struct ChainablePath : IFormattable +#else + [global::Microsoft.CodeAnalysis.Embedded] + [global::System.Diagnostics.DebuggerNonUserCode] + [TypeConverter(typeof(ChainablePathTypeConverter))] +#if PATHY_SYSTEM_TEXT_JSON + [JsonConverter(typeof(ChainablePathJsonConverter))] +#endif + internal readonly record struct ChainablePath : IFormattable +#endif #endif { private readonly string path = string.Empty; @@ -481,6 +499,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. /// 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.