From 32bf08bf7db958d7a04f8c8669180198d12e0c58 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Mon, 21 Sep 2026 01:51:55 +0200 Subject: [PATCH] IPath.WithExtension: behavior and documentation improvements --- CHANGELOG.md | 1 + TruePath.Tests/PathExtensionsTests.cs | 64 ++++++++++++- TruePath/PathExtensions.cs | 125 +++++++++++++++++++++++--- 3 files changed, 174 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ee3ea5..ff9e7dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - **Breaking:** `LocalPath.StartsWith` and `AbsolutePath.StartsWith` now compare whole path segments instead of raw strings, which makes them exact inverses of `IsPrefixOf` as originally intended in [#43](https://github.com/ForNeVeR/TruePath/issues/43). For example, `new LocalPath("/foo1").StartsWith(new LocalPath("/foo"))` is now `false`, where it used to be `true`. - `LocalPath.IsPrefixOf` now returns `false` whenever the two paths differ in absoluteness: an absolute path is never a prefix of a relative one, nor the other way round. Previously, the result depended on an incidental string comparison. - `LocalPath.IsPrefixOf` now treats an empty path — the normalized form of `""`, `"."` and `"a/.."`, and the parent of any single-segment relative path — as the current directory, so it is a prefix of every relative path that does not begin with a `..` reference. +- **Breaking:** `PathExtensions.WithExtension` now throws an `ArgumentException` unless the path ends with a file name and the result of the change is a file name in its place: an extension change may no longer alter the number or the kind of a path's segments. It used to do so silently — `new LocalPath(@"C:\foo\.gitignore").WithExtension(null)` returned `C:\foo`, `new LocalPath(".gitignore").WithExtension(null)` returned the empty path, and `new LocalPath(@"C:\").WithExtension("bar")` returned `C:\.bar`. ### Fixed - `LocalPath.IsPrefixOf` and `StartsWith` now compare path strings ordinally. Previously they used the current culture, which ignores collation-ignorable characters, so a path could be reported as a prefix of an unrelated one. diff --git a/TruePath.Tests/PathExtensionsTests.cs b/TruePath.Tests/PathExtensionsTests.cs index b807d88..65d89ee 100644 --- a/TruePath.Tests/PathExtensionsTests.cs +++ b/TruePath.Tests/PathExtensionsTests.cs @@ -75,9 +75,7 @@ public void FileNameInvariantTests(string inputPath) } [Theory] - [InlineData(@"C:\", "bar", @"C:\.bar")] [InlineData(@"C:\filename.foo", "bar", @"C:\filename.bar")] - [InlineData(@"\", "bar", @"\.bar")] [InlineData(@"\file", "bar", @"\file.bar")] [InlineData(@"\file", ".bar", @"\file.bar")] [InlineData(@"\file.", ".bar", @"\file.bar")] @@ -123,7 +121,6 @@ public void WithExtensionArgumentTests(string inputPath, string? newExtension, s } [Theory] - [InlineData("/", "bar", "/.bar")] [InlineData("/file", "bar", "/file.bar")] [InlineData("/file", ".bar", "/file.bar")] [InlineData("/file.", ".bar", "/file.bar")] @@ -145,4 +142,65 @@ public void WithExtensionTests_Unix(string inputPath, string newExtension, strin // Assert Assert.Equal(expected, newPath.Value); } + + [Theory] + [InlineData(".gitignore", null)] + [InlineData(".gitignore", "")] + [InlineData(".gitignore", ".")] + [InlineData("foo/.gitignore", null)] + [InlineData("..", null)] + [InlineData("..", "bar")] + [InlineData("../..", "txt")] + [InlineData("", "txt")] + [InlineData("a/..", "txt")] + [InlineData("file.txt", "foo/bar")] + public void WithExtensionThrowsIfTheResultIsNotAFileName(string inputPath, string? newExtension) + { + // Arrange + var path = new LocalPath(inputPath); + + // Act & Assert + Assert.Throws(() => path.WithExtension(newExtension)); + } + + [Theory] + [InlineData(@"C:\foo\.gitignore", null)] + [InlineData(@"C:\.gitignore", null)] + [InlineData(@"C:\", "bar")] + [InlineData(@"\", "bar")] + public void WithExtensionThrowsIfTheResultIsNotAFileName_Windows(string inputPath, string? newExtension) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + // Arrange + var local = new LocalPath(inputPath); + var absolute = new AbsolutePath(inputPath); + + // Act & Assert + Assert.Throws(() => local.WithExtension(newExtension)); + Assert.Throws(() => absolute.WithExtension(newExtension)); + } + + [Theory] + [InlineData("/foo/.gitignore", null)] + [InlineData("/.gitignore", null)] + [InlineData("/", "bar")] + public void WithExtensionThrowsIfTheResultIsNotAFileName_Unix(string inputPath, string? newExtension) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && !RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return; + } + + // Arrange + var local = new LocalPath(inputPath); + var absolute = new AbsolutePath(inputPath); + + // Act & Assert + Assert.Throws(() => local.WithExtension(newExtension)); + Assert.Throws(() => absolute.WithExtension(newExtension)); + } } diff --git a/TruePath/PathExtensions.cs b/TruePath/PathExtensions.cs index 1131de2..d792f78 100644 --- a/TruePath/PathExtensions.cs +++ b/TruePath/PathExtensions.cs @@ -52,8 +52,8 @@ public static string GetFilenameWithoutExtension(this IPath path) => #if NET8_0_OR_GREATER /// - /// Returns a new path of the same type with the extension of its file name component changed, - /// or with a new extension-like component if the original file name was empty. + /// Returns a new path of the same type with the extension of its file name + /// component changed. /// /// The type of the path, which must implement . /// The original path. @@ -66,11 +66,28 @@ public static string GetFilenameWithoutExtension(this IPath path) => /// Pass to remove the extension entirely (file.txt becomes file), or /// an empty string to remove it but keep the trailing dot (file.txt becomes file.). /// + /// + /// The extension may not contain a directory separator, and neither , an empty + /// string nor a lone dot may be passed for a file name that consists entirely of an extension: see the + /// exception below. + /// /// /// /// A new path of type with the modified file name component. /// The original object is not modified. /// + /// + /// + /// Thrown if the has no file name to change: it is empty (which designates the + /// current directory), it consists of a root or a drive designator alone, such as C:\, / + /// or C:, or its last segment is a parent directory reference (..). + /// + /// + /// Also thrown if the requested change would leave something other than a file name in the file name's + /// place: C:\foo\.gitignore with would leave nothing at all, and + /// file.txt with the extension foo/bar would leave a whole new segment. + /// + /// /// /// /// Only the last extension is replaced: for archive.tar.gz and the extension zip, the result @@ -81,15 +98,23 @@ public static string GetFilenameWithoutExtension(this IPath path) => /// /// A file name consisting entirely of an extension is treated as an extension, consistently with /// : .gitignore with the extension hgignore becomes - /// .hgignore. + /// .hgignore. Removing the extension of such a name is impossible, though: nothing would + /// be left of the file name. + /// + /// + /// This method changes the extension and nothing else: the path always ends with a file name both + /// before and after the call, and the number and the kind of its segments are always the same. Whenever + /// the requested change would break that, it throws instead of returning a path of a different shape. /// /// - public static TPath WithExtension(this TPath path, string? extension) where TPath : IPath => - TPath.Create(Path.ChangeExtension(((IPath)path).Value, extension)); + public static TPath WithExtension(this TPath path, string? extension) where TPath : IPath + { + var p = (IPath)path; + return TPath.Create(ChangeExtension(p.Value, p.FileName, extension)); + } #else /// - /// Returns a new path of type with the extension of its file name component changed, - /// or with a new extension-like component if the original file name was empty. + /// Returns a new path of type with the extension of its file name component changed. /// /// The original path. /// @@ -101,11 +126,28 @@ public static TPath WithExtension(this TPath path, string? extension) whe /// Pass to remove the extension entirely (file.txt becomes file), or /// an empty string to remove it but keep the trailing dot (file.txt becomes file.). /// + /// + /// The extension may not contain a directory separator, and neither , an empty + /// string nor a lone dot may be passed for a file name that consists entirely of an extension: see the + /// exception below. + /// /// /// /// A new path of type with the modified file name component. /// The original object is not modified. /// + /// + /// + /// Thrown if the has no file name to change: it is empty (which designates the + /// current directory), it consists of a root or a drive designator alone, such as C:\, / + /// or C:, or its last segment is a parent directory reference (..). + /// + /// + /// Also thrown if the requested change would leave something other than a file name in the file name's + /// place: C:\foo\.gitignore with would leave nothing at all, and + /// file.txt with the extension foo/bar would leave a whole new segment. + /// + /// /// /// /// Only the last extension is replaced: for archive.tar.gz and the extension zip, the result @@ -116,15 +158,20 @@ public static TPath WithExtension(this TPath path, string? extension) whe /// /// A file name consisting entirely of an extension is treated as an extension, consistently with /// : .gitignore with the extension hgignore becomes - /// .hgignore. + /// .hgignore. Removing the extension of such a name is impossible, though: nothing would + /// be left of the file name. + /// + /// + /// This method changes the extension and nothing else: the path always ends with a file name both + /// before and after the call, and the number and the kind of its segments are always the same. Whenever + /// the requested change would break that, it throws instead of returning a path of a different shape. /// /// public static AbsolutePath WithExtension(this AbsolutePath path, string? extension) => - AbsolutePath.Create(Path.ChangeExtension(path.Value, extension)); + AbsolutePath.Create(ChangeExtension(path.Value, path.FileName, extension)); /// - /// Returns a new path of type with the extension of its file name component changed, - /// or with a new extension-like component if the original file name was empty. + /// Returns a new path of type with the extension of its file name component changed. /// /// The original path. /// @@ -136,11 +183,28 @@ public static AbsolutePath WithExtension(this AbsolutePath path, string? extensi /// Pass to remove the extension entirely (file.txt becomes file), or /// an empty string to remove it but keep the trailing dot (file.txt becomes file.). /// + /// + /// The extension may not contain a directory separator, and neither , an empty + /// string nor a lone dot may be passed for a file name that consists entirely of an extension: see the + /// exception below. + /// /// /// /// A new path of type with the modified file name component. /// The original object is not modified. /// + /// + /// + /// Thrown if the has no file name to change: it is empty (which designates the + /// current directory), it consists of a root or a drive designator alone, such as C:\, / + /// or C:, or its last segment is a parent directory reference (..). + /// + /// + /// Also thrown if the requested change would leave something other than a file name in the file name's + /// place: C:\foo\.gitignore with would leave nothing at all, and + /// file.txt with the extension foo/bar would leave a whole new segment. + /// + /// /// /// /// Only the last extension is replaced: for archive.tar.gz and the extension zip, the result @@ -151,10 +215,45 @@ public static AbsolutePath WithExtension(this AbsolutePath path, string? extensi /// /// A file name consisting entirely of an extension is treated as an extension, consistently with /// : .gitignore with the extension hgignore becomes - /// .hgignore. + /// .hgignore. Removing the extension of such a name is impossible, though: nothing would + /// be left of the file name. + /// + /// + /// This method changes the extension and nothing else: the path always ends with a file name both + /// before and after the call, and the number and the kind of its segments are always the same. Whenever + /// the requested change would break that, it throws instead of returning a path of a different shape. /// /// public static LocalPath WithExtension(this LocalPath path, string? extension) => - LocalPath.Create(Path.ChangeExtension(path.Value, extension)); + LocalPath.Create(ChangeExtension(path.Value, path.FileName, extension)); #endif + + private static bool IsFileName(string segment) => + segment.Length > 0 + && segment is not ("." or "..") + && segment.IndexOf(Path.DirectorySeparatorChar) < 0 + && segment.IndexOf(Path.AltDirectorySeparatorChar) < 0; + + private static string ChangeExtension(string value, string fileName, string? extension) + { + if (!IsFileName(fileName)) + throw new ArgumentException( + $"Path \"{value}\" does not end with a file name, so its extension cannot be changed.", + "path"); + + // A file name consisting entirely of an extension, such as ".gitignore", has nothing left once the extension + // is removed: the new file name would either be empty or a lone dot that the normalization drops, and so the + // path would lose a segment. + var newFileName = Path.ChangeExtension(fileName, extension); + if (!IsFileName(newFileName)) + { + var extensionText = extension is null ? "null" : $"\"{extension}\""; + throw new ArgumentException( + $"Changing the extension of path \"{value}\" to {extensionText} would replace its file name " + + $"\"{fileName}\" with \"{newFileName}\", which is not a file name.", + nameof(extension)); + } + + return Path.ChangeExtension(value, extension); + } }